diff --git a/portal/README.md b/portal/README.md index a6f5867..6c246fb 100644 --- a/portal/README.md +++ b/portal/README.md @@ -32,6 +32,7 @@ A customer signs in to their own branded portal, sees their meters' live + histo | Layer | Technology | |---|---| +| Run modes | `RunMode=Client` (per-customer, default) or `RunMode=Admin` (fleet aggregation) — same binary, config-selected. See [docs/FLEET-DESIGN.md](./docs/FLEET-DESIGN.md). | | 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 | @@ -124,18 +125,31 @@ All configurable values are declared in `src/Tau.Acuvim.Portal/appsettings.templ - Docker Desktop - `dotnet-ef` tool: `dotnet tool install --global dotnet-ef` -### First-time: generate the initial migration +### First-time: generate the initial migrations -Identity / branding / rates / monitoring entities are defined in code; the migration files themselves are generated artifacts. Run once: +Two `DbContext` classes — one per `RunMode` — each with its own migration folder. ```powershell cd C:\AcuvimDev\Tau.Acuvim\portal + +# Client (RunMode=Client, default) — identity + branding + monitoring + rates dotnet ef migrations add InitialCreate ` + --context AppDbContext ` --project src/Tau.Acuvim.Portal/Tau.Acuvim.Portal.csproj ` --output-dir Migrations + +# Admin (RunMode=Admin) — identity + branding + fleet +$env:Application__RunMode='Admin' +$env:Database__ConnectionString='Host=localhost;Database=stub;Username=u;Password=p' # parsed only +dotnet ef migrations add InitialFleet ` + --context AdminDbContext ` + --project src/Tau.Acuvim.Portal/Tau.Acuvim.Portal.csproj ` + --output-dir Migrations/Admin +Remove-Item Env:Application__RunMode +Remove-Item Env:Database__ConnectionString ``` -Commit the resulting `Migrations/` folder. From then on, `MigrateAsync` on startup applies whatever exists — no manual step at deploy time. +Commit both `Migrations/` and `Migrations/Admin/`. `MigrateAsync` on startup applies whatever exists for the active context. ### Option A: full stack in Docker (recommended) @@ -367,3 +381,23 @@ See [TESTING.md](./TESTING.md) for the full manual integration scenario, fronten ## Operations For per-customer provisioning, secret rotation, backups, and health monitoring see [OPERATIONS.md](./OPERATIONS.md). + +## Admin / Fleet mode + +A second deployment of the same image — `RunMode=Admin`, separate DB — aggregates data from all customer stacks for a fleet-wide operator view. See [docs/FLEET-DESIGN.md](./docs/FLEET-DESIGN.md) for the full design. + +**Phase 13 (this release):** the Admin stack runs, the Customers page registers customers and issues push tokens (shown once). Customer stacks pick up `RunMode=Client` + `FleetIngest__*` config but don't yet push — that lands in Phase 14. + +**Spin up an Admin stack:** +```powershell +docker exec createdb -U power_user admin_fleet # one-time + +docker run -d --name admin-portal --network ` + -e Application__RunMode=Admin ` + -e Database__ConnectionString='Host=;Port=5432;Database=admin_fleet;Username=power_user;Password=' ` + -e Authentication__DefaultAdminPassword= ` + -p 8090:8080 ` + portal-dev-portal +``` + +Then sign in at `http://localhost:8090` → **Customers** → register the customer → token shown once. Drop the token into the customer stack's `.env` as `FleetIngest__Token` (Phase 14 uses it). diff --git a/portal/frontend/src/App.tsx b/portal/frontend/src/App.tsx index aff97d5..5eef3e9 100644 --- a/portal/frontend/src/App.tsx +++ b/portal/frontend/src/App.tsx @@ -2,6 +2,7 @@ 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 { AppInfoProvider } from './hooks/useAppInfo'; import { ThemedRoot } from './components/ThemedRoot'; import { RequireAuth } from './components/RequireAuth'; import { RequireRole } from './components/RequireRole'; @@ -10,6 +11,7 @@ import { LoginPage } from './pages/LoginPage'; import { DashboardPage } from './pages/DashboardPage'; import { DashboardsPage } from './pages/DashboardsPage'; import { AdminSitesPage } from './pages/AdminSitesPage'; +import { AdminCustomersPage } from './pages/AdminCustomersPage'; import { SettingsPage } from './pages/SettingsPage'; const queryClient = new QueryClient({ @@ -19,46 +21,56 @@ const queryClient = new QueryClient({ export default function App() { return ( - - - - - - } /> - - - - } - > - } /> - } /> + + + + + + + } /> - - + + + } - /> - - - - } - /> - } /> - - } /> - - - - - + > + } /> + } /> + + + + } + /> + + + + } + /> + + + + } + /> + } /> + + } /> + + + + + + ); } diff --git a/portal/frontend/src/api/appInfo.ts b/portal/frontend/src/api/appInfo.ts new file mode 100644 index 0000000..232bdfc --- /dev/null +++ b/portal/frontend/src/api/appInfo.ts @@ -0,0 +1,14 @@ +import { api } from './client'; + +export type RunMode = 'Client' | 'Admin'; + +export interface AppRuntimeInfo { + runMode: RunMode; + applicationName: string; + version: string; +} + +export async function fetchAppInfo(): Promise { + const { data } = await api.get('/app/info'); + return data; +} diff --git a/portal/frontend/src/api/customers.ts b/portal/frontend/src/api/customers.ts new file mode 100644 index 0000000..c5c6749 --- /dev/null +++ b/portal/frontend/src/api/customers.ts @@ -0,0 +1,51 @@ +import { api } from './client'; + +export interface CustomerListItem { + id: string; + code: string; + name: string; + isActive: boolean; + tokenIssuedAt: string; + tokenRotatedAt: string | null; + firstSeenAt: string | null; + lastSeenAt: string | null; + createdAt: string; +} + +export interface CustomerWithToken { + customer: CustomerListItem; + token: string; +} + +export interface CreateCustomerPayload { + code: string; + name: string; +} + +export interface UpdateCustomerPayload { + name: string; + isActive: boolean; +} + +export async function listCustomers(): Promise { + const { data } = await api.get('/admin/customers/'); + return data; +} + +export async function createCustomer(payload: CreateCustomerPayload): Promise { + const { data } = await api.post('/admin/customers/', payload); + return data; +} + +export async function updateCustomer(id: string, payload: UpdateCustomerPayload): Promise { + await api.put(`/admin/customers/${id}`, payload); +} + +export async function rotateCustomerToken(id: string): Promise { + const { data } = await api.post(`/admin/customers/${id}/rotate-token`); + return data; +} + +export async function deleteCustomer(id: string): Promise { + await api.delete(`/admin/customers/${id}`); +} diff --git a/portal/frontend/src/components/customers/CustomerFormModal.tsx b/portal/frontend/src/components/customers/CustomerFormModal.tsx new file mode 100644 index 0000000..2d83778 --- /dev/null +++ b/portal/frontend/src/components/customers/CustomerFormModal.tsx @@ -0,0 +1,88 @@ +import { useEffect } from 'react'; +import { Modal, Form, Input, Switch, Alert, Typography } from 'antd'; +import type { CustomerListItem, CreateCustomerPayload, UpdateCustomerPayload } from '../../api/customers'; + +const { Text } = Typography; + +type Mode = { kind: 'create' } | { kind: 'edit'; customer: CustomerListItem }; + +interface Props { + open: boolean; + mode: Mode | null; + submitting: boolean; + error?: string | null; + onClose: () => void; + onSubmit: (payload: CreateCustomerPayload | UpdateCustomerPayload) => void; +} + +interface FormShape { + code: string; + name: string; + isActive: boolean; +} + +export function CustomerFormModal({ open, mode, submitting, error, onClose, onSubmit }: Props) { + const [form] = Form.useForm(); + const isEdit = mode?.kind === 'edit'; + + useEffect(() => { + if (!open) return; + if (mode?.kind === 'edit') { + form.setFieldsValue({ + code: mode.customer.code, + name: mode.customer.name, + isActive: mode.customer.isActive, + }); + } else { + form.resetFields(); + form.setFieldsValue({ isActive: true }); + } + }, [open, mode, form]); + + const handleFinish = (values: FormShape) => { + if (isEdit) { + onSubmit({ name: values.name.trim(), isActive: values.isActive }); + } else { + onSubmit({ code: values.code.trim().toUpperCase(), name: values.name.trim() }); + } + }; + + return ( + form.submit()} + confirmLoading={submitting} + > + {error && } + form={form} layout="vertical" onFinish={handleFinish} requiredMark={false}> + + + + + + + {isEdit && ( + + + + )} + {!isEdit && ( + + A push token will be generated and shown once. Set it as FleetIngest__Token in + the customer's environment. + + )} + + + ); +} diff --git a/portal/frontend/src/components/customers/TokenShownOnceModal.tsx b/portal/frontend/src/components/customers/TokenShownOnceModal.tsx new file mode 100644 index 0000000..f0b17c3 --- /dev/null +++ b/portal/frontend/src/components/customers/TokenShownOnceModal.tsx @@ -0,0 +1,69 @@ +import { useState } from 'react'; +import { Modal, Typography, Alert, Input, Button, Space, message } from 'antd'; +import { CopyOutlined } from '@ant-design/icons'; + +const { Text, Paragraph } = Typography; + +interface Props { + open: boolean; + customerCode: string | null; + token: string | null; + onClose: () => void; +} + +// Shown exactly once after create or rotate. Token never re-fetchable; only the SHA-256 hash is stored. +export function TokenShownOnceModal({ open, customerCode, token, onClose }: Props) { + const [confirmed, setConfirmed] = useState(false); + + const copy = async () => { + if (!token) return; + try { + await navigator.clipboard.writeText(token); + message.success('Token copied to clipboard'); + } catch { + message.error('Copy failed — select and copy manually'); + } + }; + + const handleClose = () => { + setConfirmed(false); + onClose(); + }; + + return ( + + Done + + } + > + + + Set this as FleetIngest__Token in the customer's .env, + alongside FleetIngest__Url and FleetIngest__Enabled=true. + + + + + + + + + + ); +} diff --git a/portal/frontend/src/components/layout/AppLayout.tsx b/portal/frontend/src/components/layout/AppLayout.tsx index bdffa4a..659c69f 100644 --- a/portal/frontend/src/components/layout/AppLayout.tsx +++ b/portal/frontend/src/components/layout/AppLayout.tsx @@ -1,11 +1,12 @@ -import { Layout, Menu, Button, Typography, Space } from 'antd'; +import { Layout, Menu, Button, Typography, Space, Tag } from 'antd'; import { DashboardOutlined, SettingOutlined, LogoutOutlined, - LineChartOutlined, ApartmentOutlined, + LineChartOutlined, ApartmentOutlined, TeamOutlined, } from '@ant-design/icons'; import { Outlet, useLocation, useNavigate } from 'react-router-dom'; import { useAuth } from '../../hooks/useAuth'; import { useBranding } from '../../hooks/useBranding'; +import { useAppInfo } from '../../hooks/useAppInfo'; const { Header, Sider, Content, Footer } = Layout; const { Text } = Typography; @@ -13,23 +14,25 @@ const { Text } = Typography; export function AppLayout() { const { user, logout } = useAuth(); const { branding } = useBranding(); + const { isAdmin: adminMode } = useAppInfo(); const navigate = useNavigate(); const location = useLocation(); - const isAdmin = user?.roles.includes('Admin') ?? false; + const userIsAdmin = user?.roles.includes('Admin') ?? false; const handleLogout = async () => { await logout(); navigate('/login', { replace: true }); }; + const adminItems = adminMode + ? [{ key: '/admin/customers', icon: , label: 'Customers' }] + : [{ key: '/admin/sites', icon: , label: 'Sites' }]; + const items = [ { key: '/', icon: , label: 'Dashboard' }, { key: '/dashboards', icon: , label: 'Dashboards' }, - ...(isAdmin - ? [ - { key: '/admin/sites', icon: , label: 'Sites' }, - { key: '/settings', icon: , label: 'Settings' }, - ] + ...(userIsAdmin + ? [...adminItems, { key: '/settings', icon: , label: 'Settings' }] : []), ]; @@ -55,6 +58,7 @@ export function AppLayout() { {branding.applicationName} + {adminMode && FLEET ADMIN} {user?.displayName ?? user?.email} diff --git a/portal/frontend/src/hooks/useAppInfo.tsx b/portal/frontend/src/hooks/useAppInfo.tsx new file mode 100644 index 0000000..a7ad42d --- /dev/null +++ b/portal/frontend/src/hooks/useAppInfo.tsx @@ -0,0 +1,48 @@ +import { createContext, useContext, useMemo } from 'react'; +import type { ReactNode } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { fetchAppInfo, type AppRuntimeInfo, type RunMode } from '../api/appInfo'; + +const FALLBACK: AppRuntimeInfo = { + runMode: 'Client', + applicationName: 'Power Monitoring Portal', + version: '0.0.0', +}; + +interface AppInfoContextValue { + info: AppRuntimeInfo; + isAdmin: boolean; + isClient: boolean; + loading: boolean; +} + +const AppInfoContext = createContext(undefined); + +export function AppInfoProvider({ children }: { children: ReactNode }) { + const { data, isLoading } = useQuery({ + queryKey: ['app-info'], + queryFn: fetchAppInfo, + staleTime: 5 * 60_000, + }); + + const info = useMemo(() => data ?? FALLBACK, [data]); + const value = useMemo( + () => ({ + info, + isAdmin: info.runMode === 'Admin', + isClient: info.runMode === 'Client', + loading: isLoading, + }), + [info, isLoading], + ); + + return {children}; +} + +export function useAppInfo(): AppInfoContextValue { + const ctx = useContext(AppInfoContext); + if (!ctx) throw new Error('useAppInfo must be used inside AppInfoProvider'); + return ctx; +} + +export type { RunMode }; diff --git a/portal/frontend/src/pages/AdminCustomersPage.tsx b/portal/frontend/src/pages/AdminCustomersPage.tsx new file mode 100644 index 0000000..f61b172 --- /dev/null +++ b/portal/frontend/src/pages/AdminCustomersPage.tsx @@ -0,0 +1,183 @@ +import { useState } from 'react'; +import { Card, Table, Button, Space, Tag, Popconfirm, Tooltip, Typography, message } from 'antd'; +import type { ColumnsType } from 'antd/es/table'; +import { PlusOutlined, EditOutlined, DeleteOutlined, ReloadOutlined } from '@ant-design/icons'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { + listCustomers, createCustomer, updateCustomer, rotateCustomerToken, deleteCustomer, + type CustomerListItem, type CreateCustomerPayload, type UpdateCustomerPayload, +} from '../api/customers'; +import { CustomerFormModal } from '../components/customers/CustomerFormModal'; +import { TokenShownOnceModal } from '../components/customers/TokenShownOnceModal'; + +const { Text } = Typography; + +type FormMode = { kind: 'create' } | { kind: 'edit'; customer: CustomerListItem }; + +export function AdminCustomersPage() { + const qc = useQueryClient(); + const [formMode, setFormMode] = useState(null); + const [formError, setFormError] = useState(null); + const [tokenModal, setTokenModal] = useState<{ open: boolean; code: string | null; token: string | null }>({ + open: false, code: null, token: null, + }); + + const { data: customers = [], isLoading } = useQuery({ + queryKey: ['admin', 'customers'], + queryFn: listCustomers, + }); + + const invalidate = () => qc.invalidateQueries({ queryKey: ['admin', 'customers'] }); + + const createMut = useMutation({ + mutationFn: (p: CreateCustomerPayload) => createCustomer(p), + onSuccess: (result) => { + setFormMode(null); + setFormError(null); + setTokenModal({ open: true, code: result.customer.code, token: result.token }); + invalidate(); + }, + onError: (err: unknown) => setFormError(extractError(err)), + }); + + const updateMut = useMutation({ + mutationFn: ({ id, payload }: { id: string; payload: UpdateCustomerPayload }) => updateCustomer(id, payload), + onSuccess: () => { + message.success('Customer updated'); + setFormMode(null); + setFormError(null); + invalidate(); + }, + onError: (err: unknown) => setFormError(extractError(err)), + }); + + const rotateMut = useMutation({ + mutationFn: (id: string) => rotateCustomerToken(id), + onSuccess: (result) => { + setTokenModal({ open: true, code: result.customer.code, token: result.token }); + invalidate(); + }, + onError: (err: unknown) => message.error(extractError(err)), + }); + + const deleteMut = useMutation({ + mutationFn: (id: string) => deleteCustomer(id), + onSuccess: () => { message.success('Customer deleted'); invalidate(); }, + onError: (err: unknown) => message.error(extractError(err)), + }); + + const handleSubmit = (payload: CreateCustomerPayload | UpdateCustomerPayload) => { + setFormError(null); + if (formMode?.kind === 'edit') { + updateMut.mutate({ id: formMode.customer.id, payload: payload as UpdateCustomerPayload }); + } else { + createMut.mutate(payload as CreateCustomerPayload); + } + }; + + const columns: ColumnsType = [ + { title: 'Code', dataIndex: 'code', key: 'code', render: (v) => {v} }, + { title: 'Name', dataIndex: 'name', key: 'name' }, + { + title: 'Status', + dataIndex: 'isActive', + key: 'isActive', + render: (v: boolean) => (v ? Active : Disabled), + }, + { + title: 'Last push', + dataIndex: 'lastSeenAt', + key: 'lastSeenAt', + render: (v: string | null) => + v ? new Date(v).toLocaleString() : Never, + }, + { + title: 'Token issued', + key: 'token', + render: (_, c) => { + const ts = c.tokenRotatedAt ?? c.tokenIssuedAt; + return new Date(ts).toLocaleDateString(); + }, + }, + { + title: 'Actions', + key: 'actions', + render: (_, c) => ( + + + + rotateMut.mutate(c.id)} + > + + + + deleteMut.mutate(c.id)} + > + + + + ), + }, + ]; + + return ( + } + onClick={() => { setFormError(null); setFormMode({ kind: 'create' }); }} + > + Register customer + + } + > + + rowKey="id" + columns={columns} + dataSource={customers} + loading={isLoading} + pagination={{ pageSize: 25 }} + /> + + { setFormMode(null); setFormError(null); }} + onSubmit={handleSubmit} + /> + + setTokenModal({ open: false, code: null, token: null })} + /> + + ); +} + +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 'Request failed.'; +} diff --git a/portal/src/Tau.Acuvim.Portal/Configuration/PortalOptions.cs b/portal/src/Tau.Acuvim.Portal/Configuration/PortalOptions.cs index 434560d..9cfc7a9 100644 --- a/portal/src/Tau.Acuvim.Portal/Configuration/PortalOptions.cs +++ b/portal/src/Tau.Acuvim.Portal/Configuration/PortalOptions.cs @@ -1,5 +1,7 @@ namespace Tau.Acuvim.Portal.Configuration; +public enum RunMode { Client, Admin } + public sealed class ApplicationOptions { public const string SectionName = "Application"; @@ -7,6 +9,20 @@ public sealed class ApplicationOptions public string Name { get; set; } = "Power Monitoring Portal"; public string Environment { get; set; } = "Development"; public string PublicUrl { get; set; } = "http://localhost:8080"; + public RunMode RunMode { get; set; } = RunMode.Client; +} + +public sealed class FleetIngestOptions +{ + public const string SectionName = "FleetIngest"; + + // Push-side (Client mode) settings — Phase 14 wires the actual service. + public bool Enabled { get; set; } + public string Url { get; set; } = string.Empty; + public string Token { get; set; } = string.Empty; + public int IntervalSeconds { get; set; } = 60; + public int BatchSize { get; set; } = 5000; + public int BatchMaxBytes { get; set; } = 1_048_576; } public sealed class DatabaseOptions diff --git a/portal/src/Tau.Acuvim.Portal/Constants/Roles.cs b/portal/src/Tau.Acuvim.Portal/Constants/Roles.cs index c2f07e7..008fc11 100644 --- a/portal/src/Tau.Acuvim.Portal/Constants/Roles.cs +++ b/portal/src/Tau.Acuvim.Portal/Constants/Roles.cs @@ -9,3 +9,13 @@ public static class Policies { public const string AdminOnly = "AdminOnly"; } + +public static class FleetSchema +{ + public const string Name = "fleet"; + public const string CustomersTable = "Customers"; + public const string SitesTable = "Sites"; + public const string DevicesTable = "Devices"; + public const string PowerMeasurementsTable = "PowerMeasurements"; + public const string IngestEventsTable = "IngestEvents"; +} diff --git a/portal/src/Tau.Acuvim.Portal/DTOs/AppInfoDtos.cs b/portal/src/Tau.Acuvim.Portal/DTOs/AppInfoDtos.cs new file mode 100644 index 0000000..015367f --- /dev/null +++ b/portal/src/Tau.Acuvim.Portal/DTOs/AppInfoDtos.cs @@ -0,0 +1,3 @@ +namespace Tau.Acuvim.Portal.DTOs; + +public sealed record AppRuntimeInfoDto(string RunMode, string ApplicationName, string Version); diff --git a/portal/src/Tau.Acuvim.Portal/DTOs/CustomerDtos.cs b/portal/src/Tau.Acuvim.Portal/DTOs/CustomerDtos.cs new file mode 100644 index 0000000..5440830 --- /dev/null +++ b/portal/src/Tau.Acuvim.Portal/DTOs/CustomerDtos.cs @@ -0,0 +1,19 @@ +namespace Tau.Acuvim.Portal.DTOs; + +public sealed record CustomerListItemDto( + Guid Id, + string Code, + string Name, + bool IsActive, + DateTime TokenIssuedAt, + DateTime? TokenRotatedAt, + DateTime? FirstSeenAt, + DateTime? LastSeenAt, + DateTime CreatedAt); + +public sealed record CreateCustomerRequest(string Code, string Name); + +public sealed record UpdateCustomerRequest(string Name, bool IsActive); + +// Plaintext token only returned at create + rotate. Shown ONCE in the UI. +public sealed record CustomerWithTokenDto(CustomerListItemDto Customer, string Token); diff --git a/portal/src/Tau.Acuvim.Portal/Data/AdminDbContext.cs b/portal/src/Tau.Acuvim.Portal/Data/AdminDbContext.cs new file mode 100644 index 0000000..be6ca00 --- /dev/null +++ b/portal/src/Tau.Acuvim.Portal/Data/AdminDbContext.cs @@ -0,0 +1,83 @@ +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Identity.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; +using Tau.Acuvim.Portal.Domain.Branding; +using Tau.Acuvim.Portal.Domain.Fleet; +using Tau.Acuvim.Portal.Domain.Identity; + +namespace Tau.Acuvim.Portal.Data; + +// Admin-mode DbContext: identity + branding (shared) + fleet schema. +// Used when RunMode=Admin against the central Postgres. +public class AdminDbContext : IdentityDbContext, IWhiteLabelStore +{ + public AdminDbContext(DbContextOptions options) : base(options) { } + + public DbSet WhiteLabelSettings => Set(); + public DbSet Customers => Set(); + public DbSet FleetSites => Set(); + public DbSet FleetDevices => Set(); + public DbSet FleetPowerMeasurements => Set(); + public DbSet IngestEvents => Set(); + + protected override void OnModelCreating(ModelBuilder builder) + { + base.OnModelCreating(builder); + + builder.HasDefaultSchema("app"); + SharedSchemaConfiguration.Apply(builder); + + builder.Entity(entity => + { + entity.ToTable("Customers", schema: "fleet"); + entity.HasIndex(x => x.Code).IsUnique(); + entity.HasIndex(x => x.TokenHash).IsUnique(); + }); + + builder.Entity(entity => + { + entity.ToTable("Sites", schema: "fleet"); + entity.HasKey(x => new { x.CustomerId, x.Id }); + entity.HasOne(x => x.Customer) + .WithMany() + .HasForeignKey(x => x.CustomerId) + .OnDelete(DeleteBehavior.Cascade); + }); + + builder.Entity(entity => + { + entity.ToTable("Devices", schema: "fleet"); + entity.HasKey(x => new { x.CustomerId, x.Id }); + entity.HasOne(x => x.Customer) + .WithMany() + .HasForeignKey(x => x.CustomerId) + .OnDelete(DeleteBehavior.Cascade); + entity.HasOne(x => x.Site) + .WithMany() + .HasForeignKey(x => new { x.CustomerId, x.SiteId }) + .OnDelete(DeleteBehavior.Cascade); + }); + + builder.Entity(entity => + { + entity.ToTable("PowerMeasurements", schema: "fleet"); + entity.HasKey(x => new { x.Time, x.CustomerId, x.DeviceId }); + entity.HasIndex(x => new { x.CustomerId, x.Time }).IsDescending(false, true); + entity.HasIndex(x => new { x.CustomerId, x.DeviceId, x.Time }).IsDescending(false, false, true); + entity.HasOne() + .WithMany() + .HasForeignKey(x => new { x.CustomerId, x.DeviceId }) + .OnDelete(DeleteBehavior.Cascade); + }); + + builder.Entity(entity => + { + entity.ToTable("IngestEvents", schema: "fleet"); + entity.HasIndex(x => new { x.CustomerId, x.ReceivedAt }).IsDescending(false, true); + entity.HasOne(x => x.Customer) + .WithMany() + .HasForeignKey(x => x.CustomerId) + .OnDelete(DeleteBehavior.Cascade); + }); + } +} diff --git a/portal/src/Tau.Acuvim.Portal/Data/AppDbContext.cs b/portal/src/Tau.Acuvim.Portal/Data/AppDbContext.cs index 92a7628..7bb4cf4 100644 --- a/portal/src/Tau.Acuvim.Portal/Data/AppDbContext.cs +++ b/portal/src/Tau.Acuvim.Portal/Data/AppDbContext.cs @@ -8,7 +8,9 @@ using Tau.Acuvim.Portal.Domain.Rates; namespace Tau.Acuvim.Portal.Data; -public class AppDbContext : IdentityDbContext +// Client-mode DbContext: identity + branding (shared) + monitoring + rates. +// Used when RunMode=Client. +public class AppDbContext : IdentityDbContext, IWhiteLabelStore { public AppDbContext(DbContextOptions options) : base(options) { } @@ -25,21 +27,7 @@ public class AppDbContext : IdentityDbContext().ToTable("AspNetUsers", schema: "identity"); - builder.Entity().ToTable("AspNetRoles", schema: "identity"); - builder.Entity>().ToTable("AspNetUserRoles", schema: "identity"); - builder.Entity>().ToTable("AspNetUserClaims", schema: "identity"); - builder.Entity>().ToTable("AspNetRoleClaims", schema: "identity"); - builder.Entity>().ToTable("AspNetUserLogins", schema: "identity"); - builder.Entity>().ToTable("AspNetUserTokens", schema: "identity"); - - builder.Entity(entity => - { - entity.ToTable("WhiteLabelSettings", schema: "app"); - entity.HasKey(x => x.Id); - entity.Property(x => x.Id).ValueGeneratedNever(); - }); + SharedSchemaConfiguration.Apply(builder); builder.Entity(entity => { diff --git a/portal/src/Tau.Acuvim.Portal/Data/IWhiteLabelStore.cs b/portal/src/Tau.Acuvim.Portal/Data/IWhiteLabelStore.cs new file mode 100644 index 0000000..fe4c646 --- /dev/null +++ b/portal/src/Tau.Acuvim.Portal/Data/IWhiteLabelStore.cs @@ -0,0 +1,13 @@ +using Microsoft.EntityFrameworkCore; +using Tau.Acuvim.Portal.Domain.Branding; + +namespace Tau.Acuvim.Portal.Data; + +// Mode-independent access to the WhiteLabelSettings table. +// Both AppDbContext (Client mode) and AdminDbContext (Admin mode) implement this +// so BrandingService doesn't care which context is registered. +public interface IWhiteLabelStore +{ + DbSet WhiteLabelSettings { get; } + Task SaveChangesAsync(CancellationToken cancellationToken = default); +} diff --git a/portal/src/Tau.Acuvim.Portal/Data/SharedSchemaConfiguration.cs b/portal/src/Tau.Acuvim.Portal/Data/SharedSchemaConfiguration.cs new file mode 100644 index 0000000..c30fad5 --- /dev/null +++ b/portal/src/Tau.Acuvim.Portal/Data/SharedSchemaConfiguration.cs @@ -0,0 +1,30 @@ +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Tau.Acuvim.Portal.Domain.Branding; +using Tau.Acuvim.Portal.Domain.Identity; + +namespace Tau.Acuvim.Portal.Data; + +// Shared model setup for Identity + Branding tables. Called from both AppDbContext +// (Client mode) and AdminDbContext (Admin mode) since both contexts need their own +// identity store and branding row. +internal static class SharedSchemaConfiguration +{ + public static void Apply(ModelBuilder builder) + { + builder.Entity().ToTable("AspNetUsers", schema: "identity"); + builder.Entity().ToTable("AspNetRoles", schema: "identity"); + builder.Entity>().ToTable("AspNetUserRoles", schema: "identity"); + builder.Entity>().ToTable("AspNetUserClaims", schema: "identity"); + builder.Entity>().ToTable("AspNetRoleClaims", schema: "identity"); + builder.Entity>().ToTable("AspNetUserLogins", schema: "identity"); + builder.Entity>().ToTable("AspNetUserTokens", schema: "identity"); + + builder.Entity(entity => + { + entity.ToTable("WhiteLabelSettings", schema: "app"); + entity.HasKey(x => x.Id); + entity.Property(x => x.Id).ValueGeneratedNever(); + }); + } +} diff --git a/portal/src/Tau.Acuvim.Portal/Domain/Fleet/Customer.cs b/portal/src/Tau.Acuvim.Portal/Domain/Fleet/Customer.cs new file mode 100644 index 0000000..86be0da --- /dev/null +++ b/portal/src/Tau.Acuvim.Portal/Domain/Fleet/Customer.cs @@ -0,0 +1,27 @@ +using System.ComponentModel.DataAnnotations; + +namespace Tau.Acuvim.Portal.Domain.Fleet; + +public class Customer +{ + public Guid Id { get; set; } = Guid.NewGuid(); + + [MaxLength(50)] + public string Code { get; set; } = string.Empty; + + [MaxLength(200)] + public string Name { get; set; } = string.Empty; + + [MaxLength(64)] + public string TokenHash { get; set; } = string.Empty; + + public DateTime TokenIssuedAt { get; set; } = DateTime.UtcNow; + public DateTime? TokenRotatedAt { get; set; } + + public bool IsActive { get; set; } = true; + + public DateTime? FirstSeenAt { get; set; } + public DateTime? LastSeenAt { get; set; } + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; +} diff --git a/portal/src/Tau.Acuvim.Portal/Domain/Fleet/FleetDevice.cs b/portal/src/Tau.Acuvim.Portal/Domain/Fleet/FleetDevice.cs new file mode 100644 index 0000000..848eda5 --- /dev/null +++ b/portal/src/Tau.Acuvim.Portal/Domain/Fleet/FleetDevice.cs @@ -0,0 +1,27 @@ +using System.ComponentModel.DataAnnotations; + +namespace Tau.Acuvim.Portal.Domain.Fleet; + +public class FleetDevice +{ + public Guid Id { get; set; } + + public Guid CustomerId { get; set; } + public Customer? Customer { get; set; } + + public Guid SiteId { get; set; } + public FleetSite? 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 ReceivedAt { get; set; } = DateTime.UtcNow; +} diff --git a/portal/src/Tau.Acuvim.Portal/Domain/Fleet/FleetPowerMeasurement.cs b/portal/src/Tau.Acuvim.Portal/Domain/Fleet/FleetPowerMeasurement.cs new file mode 100644 index 0000000..9384aa0 --- /dev/null +++ b/portal/src/Tau.Acuvim.Portal/Domain/Fleet/FleetPowerMeasurement.cs @@ -0,0 +1,23 @@ +using System.ComponentModel.DataAnnotations; + +namespace Tau.Acuvim.Portal.Domain.Fleet; + +public class FleetPowerMeasurement +{ + public DateTime Time { get; set; } + public Guid CustomerId { get; set; } + public Guid DeviceId { 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; } +} diff --git a/portal/src/Tau.Acuvim.Portal/Domain/Fleet/FleetSite.cs b/portal/src/Tau.Acuvim.Portal/Domain/Fleet/FleetSite.cs new file mode 100644 index 0000000..d32aad3 --- /dev/null +++ b/portal/src/Tau.Acuvim.Portal/Domain/Fleet/FleetSite.cs @@ -0,0 +1,23 @@ +using System.ComponentModel.DataAnnotations; + +namespace Tau.Acuvim.Portal.Domain.Fleet; + +public class FleetSite +{ + public Guid Id { get; set; } + + public Guid CustomerId { get; set; } + public Customer? Customer { get; set; } + + [MaxLength(200)] + public string Name { get; set; } = string.Empty; + + [MaxLength(500)] + public string? Address { get; set; } + + public int? LocalMunicipalityId { get; set; } + + public bool IsActive { get; set; } = true; + + public DateTime ReceivedAt { get; set; } = DateTime.UtcNow; +} diff --git a/portal/src/Tau.Acuvim.Portal/Domain/Fleet/IngestEvent.cs b/portal/src/Tau.Acuvim.Portal/Domain/Fleet/IngestEvent.cs new file mode 100644 index 0000000..05649d3 --- /dev/null +++ b/portal/src/Tau.Acuvim.Portal/Domain/Fleet/IngestEvent.cs @@ -0,0 +1,28 @@ +using System.ComponentModel.DataAnnotations; + +namespace Tau.Acuvim.Portal.Domain.Fleet; + +public class IngestEvent +{ + public Guid Id { get; set; } = Guid.NewGuid(); + + public Guid CustomerId { get; set; } + public Customer? Customer { get; set; } + + public DateTime ReceivedAt { get; set; } = DateTime.UtcNow; + + [MaxLength(20)] + public string BatchType { get; set; } = string.Empty; + + public int RowsAccepted { get; set; } + public int RowsRejected { get; set; } + public int BatchBytes { get; set; } + + [MaxLength(50)] + public string? ClientHwm { get; set; } + + public TimeSpan? TimeSpread { get; set; } + + [MaxLength(500)] + public string? Error { get; set; } +} diff --git a/portal/src/Tau.Acuvim.Portal/Endpoints/AdminCustomersEndpoints.cs b/portal/src/Tau.Acuvim.Portal/Endpoints/AdminCustomersEndpoints.cs new file mode 100644 index 0000000..a3255dc --- /dev/null +++ b/portal/src/Tau.Acuvim.Portal/Endpoints/AdminCustomersEndpoints.cs @@ -0,0 +1,49 @@ +using Tau.Acuvim.Portal.Constants; +using Tau.Acuvim.Portal.DTOs; +using Tau.Acuvim.Portal.Services; + +namespace Tau.Acuvim.Portal.Endpoints; + +public static class AdminCustomersEndpoints +{ + public static IEndpointRouteBuilder MapAdminCustomersEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/api/admin/customers") + .RequireAuthorization(Policies.AdminOnly) + .WithTags("Admin / Customers"); + + group.MapGet("/", async (CustomerService svc, CancellationToken ct) => + Results.Ok(await svc.ListAsync(ct))); + + group.MapPost("/", async (CreateCustomerRequest req, CustomerService svc, CancellationToken ct) => + { + try + { + var result = await svc.CreateAsync(req, ct); + return Results.Created($"/api/admin/customers/{result.Customer.Id}", result); + } + catch (ArgumentException ex) { return Results.BadRequest(new { error = ex.Message }); } + catch (InvalidOperationException ex) { return Results.Conflict(new { error = ex.Message }); } + }); + + group.MapPut("/{id:guid}", async (Guid id, UpdateCustomerRequest req, CustomerService svc, CancellationToken ct) => + { + var ok = await svc.UpdateAsync(id, req, ct); + return ok ? Results.NoContent() : Results.NotFound(); + }); + + group.MapPost("/{id:guid}/rotate-token", async (Guid id, CustomerService svc, CancellationToken ct) => + { + var result = await svc.RotateTokenAsync(id, ct); + return result is null ? Results.NotFound() : Results.Ok(result); + }); + + group.MapDelete("/{id:guid}", async (Guid id, CustomerService svc, CancellationToken ct) => + { + var ok = await svc.DeleteAsync(id, ct); + return ok ? Results.NoContent() : Results.NotFound(); + }); + + return app; + } +} diff --git a/portal/src/Tau.Acuvim.Portal/Endpoints/AppInfoEndpoints.cs b/portal/src/Tau.Acuvim.Portal/Endpoints/AppInfoEndpoints.cs new file mode 100644 index 0000000..01961f7 --- /dev/null +++ b/portal/src/Tau.Acuvim.Portal/Endpoints/AppInfoEndpoints.cs @@ -0,0 +1,24 @@ +using Microsoft.Extensions.Options; +using Tau.Acuvim.Portal.Configuration; +using Tau.Acuvim.Portal.DTOs; + +namespace Tau.Acuvim.Portal.Endpoints; + +public static class AppInfoEndpoints +{ + public static IEndpointRouteBuilder MapAppInfoEndpoints(this IEndpointRouteBuilder app) + { + app.MapGet("/api/app/info", (IOptions opts) => + { + var assembly = typeof(AppInfoEndpoints).Assembly.GetName(); + return Results.Ok(new AppRuntimeInfoDto( + opts.Value.RunMode.ToString(), + opts.Value.Name, + assembly.Version?.ToString() ?? "0.0.0")); + }) + .AllowAnonymous() + .WithTags("App"); + + return app; + } +} diff --git a/portal/src/Tau.Acuvim.Portal/Migrations/Admin/20260518075811_InitialFleet.Designer.cs b/portal/src/Tau.Acuvim.Portal/Migrations/Admin/20260518075811_InitialFleet.Designer.cs new file mode 100644 index 0000000..3d703b2 --- /dev/null +++ b/portal/src/Tau.Acuvim.Portal/Migrations/Admin/20260518075811_InitialFleet.Designer.cs @@ -0,0 +1,596 @@ +// +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.Admin +{ + [DbContext(typeof(AdminDbContext))] + [Migration("20260518075811_InitialFleet")] + partial class InitialFleet + { + /// + 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("Id") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("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", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", "identity"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", "identity"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", "identity"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("text"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", "identity"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", "identity"); + }); + + modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Branding.WhiteLabelSettings", b => + { + b.Property("Id") + .HasColumnType("integer"); + + b.Property("AccentColor") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("ApplicationName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("FooterText") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("LogoUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("PrimaryColor") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("SecondaryColor") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("WhiteLabelSettings", "app"); + }); + + modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Fleet.Customer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FirstSeenAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("LastSeenAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("TokenIssuedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TokenRotatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.ToTable("Customers", "fleet"); + }); + + modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Fleet.FleetDevice", b => + { + b.Property("CustomerId") + .HasColumnType("uuid"); + + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ExternalId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ReceivedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SiteId") + .HasColumnType("uuid"); + + b.HasKey("CustomerId", "Id"); + + b.HasIndex("CustomerId", "SiteId"); + + b.ToTable("Devices", "fleet"); + }); + + modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Fleet.FleetPowerMeasurement", b => + { + b.Property("Time") + .HasColumnType("timestamp with time zone"); + + b.Property("CustomerId") + .HasColumnType("uuid"); + + b.Property("DeviceId") + .HasColumnType("uuid"); + + b.Property("ActivePowerKw") + .HasColumnType("double precision"); + + b.Property("ApparentPowerKva") + .HasColumnType("double precision"); + + b.Property("EnergyExportedKwh") + .HasColumnType("double precision"); + + b.Property("EnergyImportedKwh") + .HasColumnType("double precision"); + + b.Property("FrequencyHz") + .HasColumnType("double precision"); + + b.Property("PowerFactor") + .HasColumnType("double precision"); + + b.Property("ReactivePowerKvar") + .HasColumnType("double precision"); + + b.Property("Source") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("VoltageV") + .HasColumnType("double precision"); + + b.HasKey("Time", "CustomerId", "DeviceId"); + + b.HasIndex("CustomerId", "Time") + .IsDescending(false, true); + + b.HasIndex("CustomerId", "DeviceId", "Time") + .IsDescending(false, false, true); + + b.ToTable("PowerMeasurements", "fleet"); + }); + + modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Fleet.FleetSite", b => + { + b.Property("CustomerId") + .HasColumnType("uuid"); + + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Address") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("LocalMunicipalityId") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ReceivedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("CustomerId", "Id"); + + b.ToTable("Sites", "fleet"); + }); + + modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Fleet.IngestEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BatchBytes") + .HasColumnType("integer"); + + b.Property("BatchType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("ClientHwm") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CustomerId") + .HasColumnType("uuid"); + + b.Property("Error") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ReceivedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RowsAccepted") + .HasColumnType("integer"); + + b.Property("RowsRejected") + .HasColumnType("integer"); + + b.Property("TimeSpread") + .HasColumnType("interval"); + + b.HasKey("Id"); + + b.HasIndex("CustomerId", "ReceivedAt") + .IsDescending(false, true); + + b.ToTable("IngestEvents", "fleet"); + }); + + modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Identity.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("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("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Tau.Acuvim.Portal.Domain.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Tau.Acuvim.Portal.Domain.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", 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", b => + { + b.HasOne("Tau.Acuvim.Portal.Domain.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Fleet.FleetDevice", b => + { + b.HasOne("Tau.Acuvim.Portal.Domain.Fleet.Customer", "Customer") + .WithMany() + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Tau.Acuvim.Portal.Domain.Fleet.FleetSite", "Site") + .WithMany() + .HasForeignKey("CustomerId", "SiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Customer"); + + b.Navigation("Site"); + }); + + modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Fleet.FleetPowerMeasurement", b => + { + b.HasOne("Tau.Acuvim.Portal.Domain.Fleet.FleetDevice", null) + .WithMany() + .HasForeignKey("CustomerId", "DeviceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Fleet.FleetSite", b => + { + b.HasOne("Tau.Acuvim.Portal.Domain.Fleet.Customer", "Customer") + .WithMany() + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Customer"); + }); + + modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Fleet.IngestEvent", b => + { + b.HasOne("Tau.Acuvim.Portal.Domain.Fleet.Customer", "Customer") + .WithMany() + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Customer"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/portal/src/Tau.Acuvim.Portal/Migrations/Admin/20260518075811_InitialFleet.cs b/portal/src/Tau.Acuvim.Portal/Migrations/Admin/20260518075811_InitialFleet.cs new file mode 100644 index 0000000..ffd9063 --- /dev/null +++ b/portal/src/Tau.Acuvim.Portal/Migrations/Admin/20260518075811_InitialFleet.cs @@ -0,0 +1,483 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Tau.Acuvim.Portal.Migrations.Admin +{ + /// + public partial class InitialFleet : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.EnsureSchema( + name: "identity"); + + migrationBuilder.EnsureSchema( + name: "fleet"); + + migrationBuilder.EnsureSchema( + name: "app"); + + migrationBuilder.CreateTable( + name: "AspNetRoles", + schema: "identity", + columns: table => new + { + Id = table.Column(type: "text", nullable: false), + Name = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + NormalizedName = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + ConcurrencyStamp = table.Column(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(type: "text", nullable: false), + DisplayName = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + IsActive = table.Column(type: "boolean", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UserName = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + NormalizedUserName = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + Email = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + NormalizedEmail = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + EmailConfirmed = table.Column(type: "boolean", nullable: false), + PasswordHash = table.Column(type: "text", nullable: true), + SecurityStamp = table.Column(type: "text", nullable: true), + ConcurrencyStamp = table.Column(type: "text", nullable: true), + PhoneNumber = table.Column(type: "text", nullable: true), + PhoneNumberConfirmed = table.Column(type: "boolean", nullable: false), + TwoFactorEnabled = table.Column(type: "boolean", nullable: false), + LockoutEnd = table.Column(type: "timestamp with time zone", nullable: true), + LockoutEnabled = table.Column(type: "boolean", nullable: false), + AccessFailedCount = table.Column(type: "integer", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUsers", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Customers", + schema: "fleet", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Code = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + Name = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + TokenHash = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + TokenIssuedAt = table.Column(type: "timestamp with time zone", nullable: false), + TokenRotatedAt = table.Column(type: "timestamp with time zone", nullable: true), + IsActive = table.Column(type: "boolean", nullable: false), + FirstSeenAt = table.Column(type: "timestamp with time zone", nullable: true), + LastSeenAt = table.Column(type: "timestamp with time zone", nullable: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Customers", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "WhiteLabelSettings", + schema: "app", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false), + ApplicationName = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + LogoUrl = table.Column(type: "character varying(500)", maxLength: 500, nullable: false), + PrimaryColor = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + SecondaryColor = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + AccentColor = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + FooterText = table.Column(type: "character varying(500)", maxLength: 500, nullable: false), + UpdatedAt = table.Column(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(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + RoleId = table.Column(type: "text", nullable: false), + ClaimType = table.Column(type: "text", nullable: true), + ClaimValue = table.Column(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(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + UserId = table.Column(type: "text", nullable: false), + ClaimType = table.Column(type: "text", nullable: true), + ClaimValue = table.Column(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(type: "text", nullable: false), + ProviderKey = table.Column(type: "text", nullable: false), + ProviderDisplayName = table.Column(type: "text", nullable: true), + UserId = table.Column(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(type: "text", nullable: false), + RoleId = table.Column(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(type: "text", nullable: false), + LoginProvider = table.Column(type: "text", nullable: false), + Name = table.Column(type: "text", nullable: false), + Value = table.Column(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: "IngestEvents", + schema: "fleet", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + CustomerId = table.Column(type: "uuid", nullable: false), + ReceivedAt = table.Column(type: "timestamp with time zone", nullable: false), + BatchType = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + RowsAccepted = table.Column(type: "integer", nullable: false), + RowsRejected = table.Column(type: "integer", nullable: false), + BatchBytes = table.Column(type: "integer", nullable: false), + ClientHwm = table.Column(type: "character varying(50)", maxLength: 50, nullable: true), + TimeSpread = table.Column(type: "interval", nullable: true), + Error = table.Column(type: "character varying(500)", maxLength: 500, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_IngestEvents", x => x.Id); + table.ForeignKey( + name: "FK_IngestEvents_Customers_CustomerId", + column: x => x.CustomerId, + principalSchema: "fleet", + principalTable: "Customers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "Sites", + schema: "fleet", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + CustomerId = table.Column(type: "uuid", nullable: false), + Name = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + Address = table.Column(type: "character varying(500)", maxLength: 500, nullable: true), + LocalMunicipalityId = table.Column(type: "integer", nullable: true), + IsActive = table.Column(type: "boolean", nullable: false), + ReceivedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Sites", x => new { x.CustomerId, x.Id }); + table.ForeignKey( + name: "FK_Sites_Customers_CustomerId", + column: x => x.CustomerId, + principalSchema: "fleet", + principalTable: "Customers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "Devices", + schema: "fleet", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + CustomerId = table.Column(type: "uuid", nullable: false), + SiteId = table.Column(type: "uuid", nullable: false), + Name = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + ExternalId = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + Description = table.Column(type: "character varying(500)", maxLength: 500, nullable: true), + IsActive = table.Column(type: "boolean", nullable: false), + ReceivedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Devices", x => new { x.CustomerId, x.Id }); + table.ForeignKey( + name: "FK_Devices_Customers_CustomerId", + column: x => x.CustomerId, + principalSchema: "fleet", + principalTable: "Customers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_Devices_Sites_CustomerId_SiteId", + columns: x => new { x.CustomerId, x.SiteId }, + principalSchema: "fleet", + principalTable: "Sites", + principalColumns: new[] { "CustomerId", "Id" }, + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "PowerMeasurements", + schema: "fleet", + columns: table => new + { + Time = table.Column(type: "timestamp with time zone", nullable: false), + CustomerId = table.Column(type: "uuid", nullable: false), + DeviceId = table.Column(type: "uuid", nullable: false), + ActivePowerKw = table.Column(type: "double precision", nullable: false), + ReactivePowerKvar = table.Column(type: "double precision", nullable: true), + ApparentPowerKva = table.Column(type: "double precision", nullable: true), + PowerFactor = table.Column(type: "double precision", nullable: true), + VoltageV = table.Column(type: "double precision", nullable: true), + FrequencyHz = table.Column(type: "double precision", nullable: true), + EnergyImportedKwh = table.Column(type: "double precision", nullable: true), + EnergyExportedKwh = table.Column(type: "double precision", nullable: true), + Source = table.Column(type: "character varying(50)", maxLength: 50, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_PowerMeasurements", x => new { x.Time, x.CustomerId, x.DeviceId }); + table.ForeignKey( + name: "FK_PowerMeasurements_Devices_CustomerId_DeviceId", + columns: x => new { x.CustomerId, x.DeviceId }, + principalSchema: "fleet", + principalTable: "Devices", + principalColumns: new[] { "CustomerId", "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_Customers_Code", + schema: "fleet", + table: "Customers", + column: "Code", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Customers_TokenHash", + schema: "fleet", + table: "Customers", + column: "TokenHash", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Devices_CustomerId_SiteId", + schema: "fleet", + table: "Devices", + columns: new[] { "CustomerId", "SiteId" }); + + migrationBuilder.CreateIndex( + name: "IX_IngestEvents_CustomerId_ReceivedAt", + schema: "fleet", + table: "IngestEvents", + columns: new[] { "CustomerId", "ReceivedAt" }, + descending: new[] { false, true }); + + migrationBuilder.CreateIndex( + name: "IX_PowerMeasurements_CustomerId_DeviceId_Time", + schema: "fleet", + table: "PowerMeasurements", + columns: new[] { "CustomerId", "DeviceId", "Time" }, + descending: new[] { false, false, true }); + + migrationBuilder.CreateIndex( + name: "IX_PowerMeasurements_CustomerId_Time", + schema: "fleet", + table: "PowerMeasurements", + columns: new[] { "CustomerId", "Time" }, + descending: new[] { false, true }); + } + + /// + 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: "IngestEvents", + schema: "fleet"); + + migrationBuilder.DropTable( + name: "PowerMeasurements", + schema: "fleet"); + + migrationBuilder.DropTable( + name: "WhiteLabelSettings", + schema: "app"); + + migrationBuilder.DropTable( + name: "AspNetRoles", + schema: "identity"); + + migrationBuilder.DropTable( + name: "AspNetUsers", + schema: "identity"); + + migrationBuilder.DropTable( + name: "Devices", + schema: "fleet"); + + migrationBuilder.DropTable( + name: "Sites", + schema: "fleet"); + + migrationBuilder.DropTable( + name: "Customers", + schema: "fleet"); + } + } +} diff --git a/portal/src/Tau.Acuvim.Portal/Migrations/Admin/AdminDbContextModelSnapshot.cs b/portal/src/Tau.Acuvim.Portal/Migrations/Admin/AdminDbContextModelSnapshot.cs new file mode 100644 index 0000000..fe14d3d --- /dev/null +++ b/portal/src/Tau.Acuvim.Portal/Migrations/Admin/AdminDbContextModelSnapshot.cs @@ -0,0 +1,593 @@ +// +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.Admin +{ + [DbContext(typeof(AdminDbContext))] + partial class AdminDbContextModelSnapshot : 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("Id") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("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", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", "identity"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", "identity"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", "identity"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("text"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", "identity"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", "identity"); + }); + + modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Branding.WhiteLabelSettings", b => + { + b.Property("Id") + .HasColumnType("integer"); + + b.Property("AccentColor") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("ApplicationName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("FooterText") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("LogoUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("PrimaryColor") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("SecondaryColor") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("WhiteLabelSettings", "app"); + }); + + modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Fleet.Customer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FirstSeenAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("LastSeenAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("TokenIssuedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TokenRotatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.ToTable("Customers", "fleet"); + }); + + modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Fleet.FleetDevice", b => + { + b.Property("CustomerId") + .HasColumnType("uuid"); + + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ExternalId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ReceivedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SiteId") + .HasColumnType("uuid"); + + b.HasKey("CustomerId", "Id"); + + b.HasIndex("CustomerId", "SiteId"); + + b.ToTable("Devices", "fleet"); + }); + + modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Fleet.FleetPowerMeasurement", b => + { + b.Property("Time") + .HasColumnType("timestamp with time zone"); + + b.Property("CustomerId") + .HasColumnType("uuid"); + + b.Property("DeviceId") + .HasColumnType("uuid"); + + b.Property("ActivePowerKw") + .HasColumnType("double precision"); + + b.Property("ApparentPowerKva") + .HasColumnType("double precision"); + + b.Property("EnergyExportedKwh") + .HasColumnType("double precision"); + + b.Property("EnergyImportedKwh") + .HasColumnType("double precision"); + + b.Property("FrequencyHz") + .HasColumnType("double precision"); + + b.Property("PowerFactor") + .HasColumnType("double precision"); + + b.Property("ReactivePowerKvar") + .HasColumnType("double precision"); + + b.Property("Source") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("VoltageV") + .HasColumnType("double precision"); + + b.HasKey("Time", "CustomerId", "DeviceId"); + + b.HasIndex("CustomerId", "Time") + .IsDescending(false, true); + + b.HasIndex("CustomerId", "DeviceId", "Time") + .IsDescending(false, false, true); + + b.ToTable("PowerMeasurements", "fleet"); + }); + + modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Fleet.FleetSite", b => + { + b.Property("CustomerId") + .HasColumnType("uuid"); + + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Address") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("LocalMunicipalityId") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ReceivedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("CustomerId", "Id"); + + b.ToTable("Sites", "fleet"); + }); + + modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Fleet.IngestEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BatchBytes") + .HasColumnType("integer"); + + b.Property("BatchType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("ClientHwm") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CustomerId") + .HasColumnType("uuid"); + + b.Property("Error") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ReceivedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RowsAccepted") + .HasColumnType("integer"); + + b.Property("RowsRejected") + .HasColumnType("integer"); + + b.Property("TimeSpread") + .HasColumnType("interval"); + + b.HasKey("Id"); + + b.HasIndex("CustomerId", "ReceivedAt") + .IsDescending(false, true); + + b.ToTable("IngestEvents", "fleet"); + }); + + modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Identity.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("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("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Tau.Acuvim.Portal.Domain.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Tau.Acuvim.Portal.Domain.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", 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", b => + { + b.HasOne("Tau.Acuvim.Portal.Domain.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Fleet.FleetDevice", b => + { + b.HasOne("Tau.Acuvim.Portal.Domain.Fleet.Customer", "Customer") + .WithMany() + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Tau.Acuvim.Portal.Domain.Fleet.FleetSite", "Site") + .WithMany() + .HasForeignKey("CustomerId", "SiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Customer"); + + b.Navigation("Site"); + }); + + modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Fleet.FleetPowerMeasurement", b => + { + b.HasOne("Tau.Acuvim.Portal.Domain.Fleet.FleetDevice", null) + .WithMany() + .HasForeignKey("CustomerId", "DeviceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Fleet.FleetSite", b => + { + b.HasOne("Tau.Acuvim.Portal.Domain.Fleet.Customer", "Customer") + .WithMany() + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Customer"); + }); + + modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Fleet.IngestEvent", b => + { + b.HasOne("Tau.Acuvim.Portal.Domain.Fleet.Customer", "Customer") + .WithMany() + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Customer"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/portal/src/Tau.Acuvim.Portal/Program.cs b/portal/src/Tau.Acuvim.Portal/Program.cs index cfa3823..b5cc3d6 100644 --- a/portal/src/Tau.Acuvim.Portal/Program.cs +++ b/portal/src/Tau.Acuvim.Portal/Program.cs @@ -38,7 +38,10 @@ try builder.Services.Configure(builder.Configuration.GetSection(WhiteLabelOptions.SectionName)); builder.Services.Configure(builder.Configuration.GetSection(AuthenticationOptions.SectionName)); builder.Services.Configure(builder.Configuration.GetSection(MonitoringOptions.SectionName)); + builder.Services.Configure(builder.Configuration.GetSection(FleetIngestOptions.SectionName)); + var applicationOptions = builder.Configuration.GetSection(ApplicationOptions.SectionName).Get() + ?? new ApplicationOptions(); var authOptions = builder.Configuration.GetSection(AuthenticationOptions.SectionName).Get() ?? new AuthenticationOptions(); var databaseOptions = builder.Configuration.GetSection(DatabaseOptions.SectionName).Get() @@ -47,29 +50,38 @@ try ?? new TimescaleDbOptions(); var whiteLabelOptions = builder.Configuration.GetSection(WhiteLabelOptions.SectionName).Get() ?? new WhiteLabelOptions(); + var fleetIngestOptions = builder.Configuration.GetSection(FleetIngestOptions.SectionName).Get() + ?? new FleetIngestOptions(); + + RunModeGuards.ValidateConfig(applicationOptions, databaseOptions, fleetIngestOptions); var resolution = ConnectionStringResolver.Resolve(databaseOptions, timescaleOptions, builder.Environment); - Log.Information("Database connection resolved via {Source}", resolution.Source); + Log.Information("RunMode={RunMode}, database connection resolved via {Source}", + applicationOptions.RunMode, resolution.Source); builder.Services.AddSingleton(new DatabaseResolutionInfo(resolution.Source)); - builder.Services.AddDbContext(options => - options.UseNpgsql(resolution.ConnectionString)); - - builder.Services - .AddIdentity(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() - .AddDefaultTokenProviders(); + // ────────────────────────────────────────────────────────────────────── + // DbContext + Identity registration — branches on RunMode. + // Only one context is registered; the other never resolves. + // ────────────────────────────────────────────────────────────────────── + if (applicationOptions.RunMode == RunMode.Client) + { + builder.Services.AddDbContext(options => options.UseNpgsql(resolution.ConnectionString)); + builder.Services.AddScoped(sp => sp.GetRequiredService()); + builder.Services + .AddIdentity(ConfigureIdentity(authOptions)) + .AddEntityFrameworkStores() + .AddDefaultTokenProviders(); + } + else + { + builder.Services.AddDbContext(options => options.UseNpgsql(resolution.ConnectionString)); + builder.Services.AddScoped(sp => sp.GetRequiredService()); + builder.Services + .AddIdentity(ConfigureIdentity(authOptions)) + .AddEntityFrameworkStores() + .AddDefaultTokenProviders(); + } builder.Services.ConfigureApplicationCookie(options => { @@ -98,10 +110,6 @@ try 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)) { @@ -110,23 +118,34 @@ try .SetApplicationName("Tau.Acuvim.Portal"); } + // ────────────────────────────────────────────────────────────────────── + // Service registration — split by mode. + // ────────────────────────────────────────────────────────────────────── builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddSingleton(); builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); builder.Services.AddSingleton(); builder.Services.AddScoped(); + if (applicationOptions.RunMode == RunMode.Client) + { + builder.Services.AddScoped(); + builder.Services.AddSingleton(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + } + else + { + builder.Services.AddScoped(); + } + builder.Services.AddHealthChecks() - .AddNpgSql(resolution.ConnectionString, name: "timescaledb", tags: new[] { "ready" }); + .AddNpgSql(resolution.ConnectionString, name: "database", tags: new[] { "ready" }); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(c => { - c.SwaggerDoc("v1", new() { Title = "Tau Acuvim Portal API", Version = "v1" }); + c.SwaggerDoc("v1", new() { Title = $"Tau Acuvim Portal API ({applicationOptions.RunMode})", Version = "v1" }); }); builder.Services.AddCors(options => @@ -151,14 +170,26 @@ try } } + await RunModeGuards.ValidateDatabaseShapeAsync(app.Services, applicationOptions.RunMode); + if (databaseOptions.MigrateOnStartup) { using var scope = app.Services.CreateScope(); - var db = scope.ServiceProvider.GetRequiredService(); - await db.Database.MigrateAsync(); - var timescale = scope.ServiceProvider.GetRequiredService(); - await timescale.EnsureHypertablesAsync(); + if (applicationOptions.RunMode == RunMode.Client) + { + var db = scope.ServiceProvider.GetRequiredService(); + await db.Database.MigrateAsync(); + + var timescale = scope.ServiceProvider.GetRequiredService(); + await timescale.EnsureHypertablesAsync(); + } + else + { + var db = scope.ServiceProvider.GetRequiredService(); + await db.Database.MigrateAsync(); + // Admin-side hypertable + continuous aggregates land in Phase 14. + } var bootstrapper = scope.ServiceProvider.GetRequiredService(); await bootstrapper.SeedAsync(); @@ -180,7 +211,7 @@ try { app.UseCors("DevSpa"); app.UseSwagger(); - app.UseSwaggerUI(c => c.SwaggerEndpoint("/swagger/v1/swagger.json", "Tau Acuvim Portal API")); + app.UseSwaggerUI(c => c.SwaggerEndpoint("/swagger/v1/swagger.json", $"Tau Acuvim Portal API ({applicationOptions.RunMode})")); } else { @@ -199,15 +230,28 @@ try app.UseAuthentication(); app.UseAuthorization(); + // ────────────────────────────────────────────────────────────────────── + // Endpoint mapping — shared first, then mode-specific. + // ────────────────────────────────────────────────────────────────────── app.MapAuthEndpoints(); app.MapAdminUserEndpoints(); app.MapBrandingEndpoints(); - app.MapRatesEndpoints(); - app.MapAdminRatesEndpoints(); - app.MapSitesEndpoints(); - app.MapMeasurementsEndpoints(); app.MapGrafanaEndpoints(); app.MapAdminConfigEndpoints(); + app.MapAppInfoEndpoints(); + + if (applicationOptions.RunMode == RunMode.Client) + { + app.MapRatesEndpoints(); + app.MapAdminRatesEndpoints(); + app.MapSitesEndpoints(); + app.MapMeasurementsEndpoints(); + } + else + { + app.MapAdminCustomersEndpoints(); + } + app.MapHealthChecks("/health", new Microsoft.AspNetCore.Diagnostics.HealthChecks.HealthCheckOptions { Predicate = _ => false @@ -229,3 +273,16 @@ finally { Log.CloseAndFlush(); } + +static Action ConfigureIdentity(AuthenticationOptions authOptions) => 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; +}; diff --git a/portal/src/Tau.Acuvim.Portal/Services/BrandingService.cs b/portal/src/Tau.Acuvim.Portal/Services/BrandingService.cs index 3621367..cef8582 100644 --- a/portal/src/Tau.Acuvim.Portal/Services/BrandingService.cs +++ b/portal/src/Tau.Acuvim.Portal/Services/BrandingService.cs @@ -8,12 +8,12 @@ using Tau.Acuvim.Portal.DTOs; namespace Tau.Acuvim.Portal.Services; public sealed class BrandingService( - AppDbContext db, + IWhiteLabelStore store, IOptions defaults) { public async Task GetAsync(CancellationToken ct = default) { - var row = await db.WhiteLabelSettings.AsNoTracking() + var row = await store.WhiteLabelSettings.AsNoTracking() .FirstOrDefaultAsync(x => x.Id == WhiteLabelSettings.SingletonId, ct); row ??= await SeedAsync(ct); return ToDto(row); @@ -21,7 +21,7 @@ public sealed class BrandingService( public async Task UpdateAsync(UpdateBrandingRequest req, CancellationToken ct = default) { - var row = await db.WhiteLabelSettings + var row = await store.WhiteLabelSettings .FirstOrDefaultAsync(x => x.Id == WhiteLabelSettings.SingletonId, ct) ?? await SeedAsync(ct); @@ -33,24 +33,24 @@ public sealed class BrandingService( if (req.LogoUrl is not null) row.LogoUrl = req.LogoUrl; row.UpdatedAt = DateTime.UtcNow; - await db.SaveChangesAsync(ct); + await store.SaveChangesAsync(ct); return ToDto(row); } public async Task SetLogoUrlAsync(string logoUrl, CancellationToken ct = default) { - var row = await db.WhiteLabelSettings + var row = await store.WhiteLabelSettings .FirstOrDefaultAsync(x => x.Id == WhiteLabelSettings.SingletonId, ct) ?? await SeedAsync(ct); row.LogoUrl = logoUrl; row.UpdatedAt = DateTime.UtcNow; - await db.SaveChangesAsync(ct); + await store.SaveChangesAsync(ct); return logoUrl; } public async Task EnsureSeededAsync(CancellationToken ct = default) { - var exists = await db.WhiteLabelSettings + var exists = await store.WhiteLabelSettings .AnyAsync(x => x.Id == WhiteLabelSettings.SingletonId, ct); if (!exists) await SeedAsync(ct); } @@ -69,8 +69,8 @@ public sealed class BrandingService( FooterText = d.FooterText, UpdatedAt = DateTime.UtcNow }; - db.WhiteLabelSettings.Add(row); - await db.SaveChangesAsync(ct); + store.WhiteLabelSettings.Add(row); + await store.SaveChangesAsync(ct); return row; } diff --git a/portal/src/Tau.Acuvim.Portal/Services/CustomerService.cs b/portal/src/Tau.Acuvim.Portal/Services/CustomerService.cs new file mode 100644 index 0000000..232c6ad --- /dev/null +++ b/portal/src/Tau.Acuvim.Portal/Services/CustomerService.cs @@ -0,0 +1,101 @@ +using System.Security.Cryptography; +using Microsoft.EntityFrameworkCore; +using Tau.Acuvim.Portal.Data; +using Tau.Acuvim.Portal.Domain.Fleet; +using Tau.Acuvim.Portal.DTOs; + +namespace Tau.Acuvim.Portal.Services; + +public sealed class CustomerService(AdminDbContext db) +{ + public async Task> ListAsync(CancellationToken ct = default) => + await db.Customers + .OrderBy(c => c.Code) + .Select(c => ToDto(c)) + .ToListAsync(ct); + + public async Task CreateAsync(CreateCustomerRequest req, CancellationToken ct = default) + { + var code = NormalizeCode(req.Code); + if (string.IsNullOrWhiteSpace(code)) + throw new ArgumentException("Code is required.", nameof(req)); + if (string.IsNullOrWhiteSpace(req.Name)) + throw new ArgumentException("Name is required.", nameof(req)); + if (await db.Customers.AnyAsync(c => c.Code == code, ct)) + throw new InvalidOperationException($"Customer with code '{code}' already exists."); + + var token = GenerateToken(); + var customer = new Customer + { + Code = code, + Name = req.Name.Trim(), + TokenHash = HashToken(token), + TokenIssuedAt = DateTime.UtcNow, + IsActive = true, + CreatedAt = DateTime.UtcNow + }; + + db.Customers.Add(customer); + await db.SaveChangesAsync(ct); + + return new CustomerWithTokenDto(ToDto(customer), token); + } + + public async Task UpdateAsync(Guid id, UpdateCustomerRequest req, CancellationToken ct = default) + { + var c = await db.Customers.FindAsync(new object?[] { id }, ct); + if (c is null) return false; + if (!string.IsNullOrWhiteSpace(req.Name)) c.Name = req.Name.Trim(); + c.IsActive = req.IsActive; + await db.SaveChangesAsync(ct); + return true; + } + + public async Task RotateTokenAsync(Guid id, CancellationToken ct = default) + { + var c = await db.Customers.FindAsync(new object?[] { id }, ct); + if (c is null) return null; + var token = GenerateToken(); + c.TokenHash = HashToken(token); + c.TokenRotatedAt = DateTime.UtcNow; + await db.SaveChangesAsync(ct); + return new CustomerWithTokenDto(ToDto(c), token); + } + + public async Task DeleteAsync(Guid id, CancellationToken ct = default) + { + var c = await db.Customers.FindAsync(new object?[] { id }, ct); + if (c is null) return false; + db.Customers.Remove(c); + await db.SaveChangesAsync(ct); + return true; + } + + public Task FindByTokenAsync(string token, CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(token)) return Task.FromResult(null); + var hash = HashToken(token); + return db.Customers.FirstOrDefaultAsync(c => c.TokenHash == hash && c.IsActive, ct); + } + + public static string GenerateToken() + { + Span bytes = stackalloc byte[32]; + RandomNumberGenerator.Fill(bytes); + return Convert.ToHexString(bytes).ToLowerInvariant(); + } + + public static string HashToken(string token) + { + Span hash = stackalloc byte[32]; + SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(token), hash); + return Convert.ToHexString(hash).ToLowerInvariant(); + } + + private static string NormalizeCode(string? code) => + (code ?? string.Empty).Trim().ToUpperInvariant(); + + private static CustomerListItemDto ToDto(Customer c) => new( + c.Id, c.Code, c.Name, c.IsActive, + c.TokenIssuedAt, c.TokenRotatedAt, c.FirstSeenAt, c.LastSeenAt, c.CreatedAt); +} diff --git a/portal/src/Tau.Acuvim.Portal/Services/RunModeGuards.cs b/portal/src/Tau.Acuvim.Portal/Services/RunModeGuards.cs new file mode 100644 index 0000000..046f2d9 --- /dev/null +++ b/portal/src/Tau.Acuvim.Portal/Services/RunModeGuards.cs @@ -0,0 +1,80 @@ +using Microsoft.EntityFrameworkCore; +using Tau.Acuvim.Portal.Configuration; +using Tau.Acuvim.Portal.Data; + +namespace Tau.Acuvim.Portal.Services; + +// Startup-time validation that prevents the obvious "pointed dev at prod DB" / +// "Admin mode against a Client DB" mistakes. +public static class RunModeGuards +{ + public static void ValidateConfig(ApplicationOptions app, DatabaseOptions database, FleetIngestOptions fleet) + { + if (app.RunMode == RunMode.Admin) + { + if (string.IsNullOrWhiteSpace(database.ConnectionString)) + { + throw new InvalidOperationException( + "RunMode=Admin requires Database:ConnectionString. " + + "AutoProvisionLocalTimescaleDb is not honoured for Admin (no obvious local default for a fleet DB)."); + } + } + + if (app.RunMode == RunMode.Client && fleet.Enabled) + { + if (string.IsNullOrWhiteSpace(fleet.Url) || string.IsNullOrWhiteSpace(fleet.Token)) + { + throw new InvalidOperationException( + "FleetIngest:Enabled=true requires both FleetIngest:Url and FleetIngest:Token. " + + "Disable FleetIngest or provide both via env vars / secrets."); + } + } + } + + // Refuse to run Client against an Admin DB (fleet.Customers exists) or vice versa. + // Cheap query — only runs once at startup. + public static async Task ValidateDatabaseShapeAsync( + IServiceProvider services, + RunMode runMode, + CancellationToken ct = default) + { + using var scope = services.CreateScope(); + + if (runMode == RunMode.Client) + { + var db = scope.ServiceProvider.GetService(); + if (db is null) return; + try + { + var hasFleet = await db.Database + .SqlQuery($@"SELECT 1 AS ""Value"" FROM information_schema.tables WHERE table_schema='fleet' AND table_name='Customers' LIMIT 1") + .ToListAsync(ct); + if (hasFleet.Count > 0) + { + throw new InvalidOperationException( + "RunMode=Client is pointed at a database that contains fleet.Customers (an Admin DB). " + + "Check Database:ConnectionString."); + } + } + catch (Microsoft.EntityFrameworkCore.DbUpdateException) { /* db not ready, migration runs next */ } + } + else + { + var db = scope.ServiceProvider.GetService(); + if (db is null) return; + try + { + var hasMonitoring = await db.Database + .SqlQuery($@"SELECT 1 AS ""Value"" FROM information_schema.tables WHERE table_schema='monitoring' AND table_name='PowerMeasurements' LIMIT 1") + .ToListAsync(ct); + if (hasMonitoring.Count > 0) + { + throw new InvalidOperationException( + "RunMode=Admin is pointed at a database that contains monitoring.PowerMeasurements (a Client DB). " + + "Check Database:ConnectionString."); + } + } + catch (Microsoft.EntityFrameworkCore.DbUpdateException) { /* db not ready */ } + } + } +} diff --git a/portal/src/Tau.Acuvim.Portal/appsettings.template.json b/portal/src/Tau.Acuvim.Portal/appsettings.template.json index 213084d..1eae423 100644 --- a/portal/src/Tau.Acuvim.Portal/appsettings.template.json +++ b/portal/src/Tau.Acuvim.Portal/appsettings.template.json @@ -2,7 +2,16 @@ "Application": { "Name": "Power Monitoring Portal", "Environment": "Development", - "PublicUrl": "http://localhost:8080" + "PublicUrl": "http://localhost:8080", + "RunMode": "Client" + }, + "FleetIngest": { + "Enabled": false, + "Url": "", + "Token": "", + "IntervalSeconds": 60, + "BatchSize": 5000, + "BatchMaxBytes": 1048576 }, "Database": { "Provider": "PostgreSQL", diff --git a/portal/tests/Tau.Acuvim.Portal.Tests/CustomerTokenTests.cs b/portal/tests/Tau.Acuvim.Portal.Tests/CustomerTokenTests.cs new file mode 100644 index 0000000..e466d6c --- /dev/null +++ b/portal/tests/Tau.Acuvim.Portal.Tests/CustomerTokenTests.cs @@ -0,0 +1,42 @@ +using Tau.Acuvim.Portal.Services; + +namespace Tau.Acuvim.Portal.Tests; + +public class CustomerTokenTests +{ + [Fact] + public void GenerateToken_Is64HexCharsLowercase() + { + var token = CustomerService.GenerateToken(); + Assert.Equal(64, token.Length); + Assert.Matches("^[0-9a-f]{64}$", token); + } + + [Fact] + public void GenerateToken_IsUniqueAcrossManyCalls() + { + var tokens = Enumerable.Range(0, 1000).Select(_ => CustomerService.GenerateToken()).ToHashSet(); + Assert.Equal(1000, tokens.Count); + } + + [Fact] + public void HashToken_IsDeterministic() + { + var token = "abcd1234"; + Assert.Equal(CustomerService.HashToken(token), CustomerService.HashToken(token)); + } + + [Fact] + public void HashToken_DiffersForDifferentInputs() + { + Assert.NotEqual(CustomerService.HashToken("abc"), CustomerService.HashToken("abd")); + } + + [Fact] + public void HashToken_Is64HexCharsLowercase() + { + var hash = CustomerService.HashToken("anything"); + Assert.Equal(64, hash.Length); + Assert.Matches("^[0-9a-f]{64}$", hash); + } +} diff --git a/portal/tests/Tau.Acuvim.Portal.Tests/RunModeGuardsTests.cs b/portal/tests/Tau.Acuvim.Portal.Tests/RunModeGuardsTests.cs new file mode 100644 index 0000000..981cc3b --- /dev/null +++ b/portal/tests/Tau.Acuvim.Portal.Tests/RunModeGuardsTests.cs @@ -0,0 +1,72 @@ +using Tau.Acuvim.Portal.Configuration; +using Tau.Acuvim.Portal.Services; + +namespace Tau.Acuvim.Portal.Tests; + +public class RunModeGuardsTests +{ + [Fact] + public void Admin_WithoutConnectionString_Throws() + { + var app = new ApplicationOptions { RunMode = RunMode.Admin }; + var db = new DatabaseOptions { ConnectionString = "" }; + var fleet = new FleetIngestOptions(); + + var ex = Assert.Throws(() => + RunModeGuards.ValidateConfig(app, db, fleet)); + Assert.Contains("RunMode=Admin requires Database:ConnectionString", ex.Message); + } + + [Fact] + public void Admin_WithConnectionString_OK() + { + var app = new ApplicationOptions { RunMode = RunMode.Admin }; + var db = new DatabaseOptions { ConnectionString = "Host=x;Database=y;Username=u;Password=p" }; + var fleet = new FleetIngestOptions(); + + RunModeGuards.ValidateConfig(app, db, fleet); + } + + [Fact] + public void Client_WithFleetEnabledMissingUrl_Throws() + { + var app = new ApplicationOptions { RunMode = RunMode.Client }; + var db = new DatabaseOptions { ConnectionString = "x", AutoProvisionLocalTimescaleDb = false }; + var fleet = new FleetIngestOptions { Enabled = true, Url = "", Token = "abc" }; + + var ex = Assert.Throws(() => + RunModeGuards.ValidateConfig(app, db, fleet)); + Assert.Contains("FleetIngest:Enabled=true requires", ex.Message); + } + + [Fact] + public void Client_WithFleetEnabledMissingToken_Throws() + { + var app = new ApplicationOptions { RunMode = RunMode.Client }; + var db = new DatabaseOptions { ConnectionString = "x", AutoProvisionLocalTimescaleDb = false }; + var fleet = new FleetIngestOptions { Enabled = true, Url = "https://x", Token = "" }; + + Assert.Throws(() => + RunModeGuards.ValidateConfig(app, db, fleet)); + } + + [Fact] + public void Client_WithFleetDisabled_OK() + { + var app = new ApplicationOptions { RunMode = RunMode.Client }; + var db = new DatabaseOptions { ConnectionString = "x", AutoProvisionLocalTimescaleDb = false }; + var fleet = new FleetIngestOptions { Enabled = false, Url = "", Token = "" }; + + RunModeGuards.ValidateConfig(app, db, fleet); + } + + [Fact] + public void Client_DefaultMode_NoFleetConfig_OK() + { + var app = new ApplicationOptions(); + var db = new DatabaseOptions { ConnectionString = "x", AutoProvisionLocalTimescaleDb = false }; + var fleet = new FleetIngestOptions(); + + RunModeGuards.ValidateConfig(app, db, fleet); + } +}