Phase 13: RunMode flag + AdminDbContext + Customers registry

Adds the plumbing for the fleet-aggregation feature without moving any
data yet. Same portal binary now supports two modes selected via
Application:RunMode (Client | Admin).

Backend
- New AdminDbContext (identity + branding shared via SharedSchemaConfiguration
  helper + fleet schema). AppDbContext keeps existing identity + branding +
  monitoring + rates; renamed implicitly the "Client" context. Only one is
  registered with DI per RunMode.
- IWhiteLabelStore interface implemented by both contexts so BrandingService
  works in either mode.
- Fleet entities: Customer, FleetSite, FleetDevice, FleetPowerMeasurement,
  IngestEvent (all in the new fleet schema). Migration in Migrations/Admin/.
- CustomerService: 32-byte random token, SHA-256 hash stored, plaintext
  shown once on create + rotate. Token lookup is a single O(log N) indexed
  query.
- RunModeGuards: refuses Admin without conn string; refuses Client+push
  without URL/token; refuses cross-DB pointing (Client at admin_fleet DB
  with fleet.Customers, or Admin at customer DB with monitoring.PowerMeasurements).
- Endpoint maps now branch on RunMode:
  Client → sites/measurements/rates/admin-sites/admin-rates
  Admin  → admin/customers
  Shared → auth, users, branding, grafana, admin-config, app/info, health
- /api/app/info (anonymous) returns {runMode, applicationName, version} so
  the SPA can drive nav without re-fetching auth state.

Frontend
- AppInfoProvider + useAppInfo hook fetch /api/app/info once on load.
- AdminCustomersPage with create / edit / rotate-token / delete.
- TokenShownOnceModal: shows token once, copy-to-clipboard, "I've stored
  it" confirmation gate before closing.
- AppLayout nav swaps Sites <-> Customers based on RunMode and shows a
  FLEET ADMIN tag in the header when in Admin mode.

Tests
- 11 new tests: CustomerTokenTests (5) + RunModeGuardsTests (6).
- 51/51 passing locally.

Verified
- dotnet build + dotnet test clean (zero errors, one EF1002 warning
  suppressed in Phase 11 already).
- Client mode docker rebuild: no regressions, /api/app/info returns
  Client, login works, /api/sites/ works.
- Admin mode spun up on port 8090 against a fresh admin_fleet DB:
  /api/app/info returns Admin, customer ABC0001 registered, 64-char
  token returned, list shows the row.
- Cross-DB guard: Client run against admin_fleet refuses with explicit
  "is pointed at a database that contains fleet.Customers" error.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Diseri Pearson 2026-05-18 10:09:41 +02:00
parent 880525b306
commit 2c618b776b
34 changed files with 3012 additions and 113 deletions

View File

@ -32,6 +32,7 @@ A customer signs in to their own branded portal, sees their meters' live + histo
| Layer | Technology | | 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 | | 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 | | Frontend | React 18 + TypeScript + Vite, Ant Design 5, TanStack Query, react-router |
| Database | TimescaleDB 2.17 on PostgreSQL 16 | | 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 - Docker Desktop
- `dotnet-ef` tool: `dotnet tool install --global dotnet-ef` - `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 ```powershell
cd C:\AcuvimDev\Tau.Acuvim\portal cd C:\AcuvimDev\Tau.Acuvim\portal
# Client (RunMode=Client, default) — identity + branding + monitoring + rates
dotnet ef migrations add InitialCreate ` dotnet ef migrations add InitialCreate `
--context AppDbContext `
--project src/Tau.Acuvim.Portal/Tau.Acuvim.Portal.csproj ` --project src/Tau.Acuvim.Portal/Tau.Acuvim.Portal.csproj `
--output-dir Migrations --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) ### Option A: full stack in Docker (recommended)
@ -367,3 +381,23 @@ See [TESTING.md](./TESTING.md) for the full manual integration scenario, fronten
## Operations ## Operations
For per-customer provisioning, secret rotation, backups, and health monitoring see [OPERATIONS.md](./OPERATIONS.md). 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 <client-timescale> createdb -U power_user admin_fleet # one-time
docker run -d --name admin-portal --network <existing-network> `
-e Application__RunMode=Admin `
-e Database__ConnectionString='Host=<host>;Port=5432;Database=admin_fleet;Username=power_user;Password=<secret>' `
-e Authentication__DefaultAdminPassword=<rotate-from-template-default> `
-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).

View File

@ -2,6 +2,7 @@ import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { AuthProvider } from './hooks/useAuth'; import { AuthProvider } from './hooks/useAuth';
import { BrandingProvider } from './hooks/useBranding'; import { BrandingProvider } from './hooks/useBranding';
import { AppInfoProvider } from './hooks/useAppInfo';
import { ThemedRoot } from './components/ThemedRoot'; import { ThemedRoot } from './components/ThemedRoot';
import { RequireAuth } from './components/RequireAuth'; import { RequireAuth } from './components/RequireAuth';
import { RequireRole } from './components/RequireRole'; import { RequireRole } from './components/RequireRole';
@ -10,6 +11,7 @@ import { LoginPage } from './pages/LoginPage';
import { DashboardPage } from './pages/DashboardPage'; import { DashboardPage } from './pages/DashboardPage';
import { DashboardsPage } from './pages/DashboardsPage'; import { DashboardsPage } from './pages/DashboardsPage';
import { AdminSitesPage } from './pages/AdminSitesPage'; import { AdminSitesPage } from './pages/AdminSitesPage';
import { AdminCustomersPage } from './pages/AdminCustomersPage';
import { SettingsPage } from './pages/SettingsPage'; import { SettingsPage } from './pages/SettingsPage';
const queryClient = new QueryClient({ const queryClient = new QueryClient({
@ -19,46 +21,56 @@ const queryClient = new QueryClient({
export default function App() { export default function App() {
return ( return (
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<BrandingProvider> <AppInfoProvider>
<ThemedRoot> <BrandingProvider>
<AuthProvider> <ThemedRoot>
<BrowserRouter> <AuthProvider>
<Routes> <BrowserRouter>
<Route path="/login" element={<LoginPage />} /> <Routes>
<Route <Route path="/login" element={<LoginPage />} />
path="/"
element={
<RequireAuth>
<AppLayout />
</RequireAuth>
}
>
<Route index element={<DashboardPage />} />
<Route path="dashboards" element={<DashboardsPage />} />
<Route <Route
path="admin/sites" path="/"
element={ element={
<RequireRole role="Admin"> <RequireAuth>
<AdminSitesPage /> <AppLayout />
</RequireRole> </RequireAuth>
} }
/> >
<Route <Route index element={<DashboardPage />} />
path="settings" <Route path="dashboards" element={<DashboardsPage />} />
element={ <Route
<RequireRole role="Admin"> path="admin/sites"
<SettingsPage /> element={
</RequireRole> <RequireRole role="Admin">
} <AdminSitesPage />
/> </RequireRole>
<Route path="admin/users" element={<Navigate to="/settings" replace />} /> }
</Route> />
<Route path="*" element={<Navigate to="/" replace />} /> <Route
</Routes> path="admin/customers"
</BrowserRouter> element={
</AuthProvider> <RequireRole role="Admin">
</ThemedRoot> <AdminCustomersPage />
</BrandingProvider> </RequireRole>
}
/>
<Route
path="settings"
element={
<RequireRole role="Admin">
<SettingsPage />
</RequireRole>
}
/>
<Route path="admin/users" element={<Navigate to="/settings" replace />} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</BrowserRouter>
</AuthProvider>
</ThemedRoot>
</BrandingProvider>
</AppInfoProvider>
</QueryClientProvider> </QueryClientProvider>
); );
} }

View File

@ -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<AppRuntimeInfo> {
const { data } = await api.get<AppRuntimeInfo>('/app/info');
return data;
}

View File

@ -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<CustomerListItem[]> {
const { data } = await api.get<CustomerListItem[]>('/admin/customers/');
return data;
}
export async function createCustomer(payload: CreateCustomerPayload): Promise<CustomerWithToken> {
const { data } = await api.post<CustomerWithToken>('/admin/customers/', payload);
return data;
}
export async function updateCustomer(id: string, payload: UpdateCustomerPayload): Promise<void> {
await api.put(`/admin/customers/${id}`, payload);
}
export async function rotateCustomerToken(id: string): Promise<CustomerWithToken> {
const { data } = await api.post<CustomerWithToken>(`/admin/customers/${id}/rotate-token`);
return data;
}
export async function deleteCustomer(id: string): Promise<void> {
await api.delete(`/admin/customers/${id}`);
}

View File

@ -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<FormShape>();
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 (
<Modal
title={isEdit ? `Edit ${mode!.customer.code}` : 'Register customer'}
open={open}
onCancel={onClose}
onOk={() => form.submit()}
confirmLoading={submitting}
>
{error && <Alert type="error" message={error} style={{ marginBottom: 16 }} />}
<Form<FormShape> form={form} layout="vertical" onFinish={handleFinish} requiredMark={false}>
<Form.Item
name="code"
label="Customer code"
tooltip="7-character identifier (e.g. ABC0001). Lowercased automatically for COMPOSE_PROJECT_NAME; uppercased here for display."
rules={[
{ required: true, message: 'Required' },
{ pattern: /^[A-Za-z0-9]{3,50}$/, message: '350 alphanumeric chars' },
]}
>
<Input disabled={isEdit} placeholder="ABC0001" maxLength={50} />
</Form.Item>
<Form.Item name="name" label="Display name" rules={[{ required: true, message: 'Required' }]}>
<Input />
</Form.Item>
{isEdit && (
<Form.Item name="isActive" label="Active" valuePropName="checked">
<Switch />
</Form.Item>
)}
{!isEdit && (
<Text type="secondary" style={{ fontSize: 12 }}>
A push token will be generated and shown once. Set it as <Text code>FleetIngest__Token</Text> in
the customer's environment.
</Text>
)}
</Form>
</Modal>
);
}

View File

@ -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 (
<Modal
title={customerCode ? `Push token for ${customerCode}` : 'Push token'}
open={open}
onCancel={handleClose}
maskClosable={false}
keyboard={false}
closable={confirmed}
footer={
<Button type="primary" disabled={!confirmed} onClick={handleClose}>
Done
</Button>
}
>
<Alert
type="warning"
showIcon
message="This token is shown ONCE."
description="We only store its SHA-256 hash. If you lose this, rotate the token to get a new one."
style={{ marginBottom: 16 }}
/>
<Paragraph>
Set this as <Text code>FleetIngest__Token</Text> in the customer's <Text code>.env</Text>,
alongside <Text code>FleetIngest__Url</Text> and <Text code>FleetIngest__Enabled=true</Text>.
</Paragraph>
<Space.Compact style={{ width: '100%' }}>
<Input.TextArea value={token ?? ''} readOnly rows={2} style={{ fontFamily: 'monospace' }} />
<Button icon={<CopyOutlined />} onClick={copy}>Copy</Button>
</Space.Compact>
<Paragraph style={{ marginTop: 16 }}>
<Button onClick={() => setConfirmed(true)} disabled={confirmed}>
{confirmed ? 'Confirmed — you can close this' : "I've stored the token securely"}
</Button>
</Paragraph>
</Modal>
);
}

View File

@ -1,11 +1,12 @@
import { Layout, Menu, Button, Typography, Space } from 'antd'; import { Layout, Menu, Button, Typography, Space, Tag } from 'antd';
import { import {
DashboardOutlined, SettingOutlined, LogoutOutlined, DashboardOutlined, SettingOutlined, LogoutOutlined,
LineChartOutlined, ApartmentOutlined, LineChartOutlined, ApartmentOutlined, TeamOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { Outlet, useLocation, useNavigate } from 'react-router-dom'; import { Outlet, useLocation, useNavigate } from 'react-router-dom';
import { useAuth } from '../../hooks/useAuth'; import { useAuth } from '../../hooks/useAuth';
import { useBranding } from '../../hooks/useBranding'; import { useBranding } from '../../hooks/useBranding';
import { useAppInfo } from '../../hooks/useAppInfo';
const { Header, Sider, Content, Footer } = Layout; const { Header, Sider, Content, Footer } = Layout;
const { Text } = Typography; const { Text } = Typography;
@ -13,23 +14,25 @@ const { Text } = Typography;
export function AppLayout() { export function AppLayout() {
const { user, logout } = useAuth(); const { user, logout } = useAuth();
const { branding } = useBranding(); const { branding } = useBranding();
const { isAdmin: adminMode } = useAppInfo();
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const isAdmin = user?.roles.includes('Admin') ?? false; const userIsAdmin = user?.roles.includes('Admin') ?? false;
const handleLogout = async () => { const handleLogout = async () => {
await logout(); await logout();
navigate('/login', { replace: true }); navigate('/login', { replace: true });
}; };
const adminItems = adminMode
? [{ key: '/admin/customers', icon: <TeamOutlined />, label: 'Customers' }]
: [{ key: '/admin/sites', icon: <ApartmentOutlined />, label: 'Sites' }];
const items = [ const items = [
{ key: '/', icon: <DashboardOutlined />, label: 'Dashboard' }, { key: '/', icon: <DashboardOutlined />, label: 'Dashboard' },
{ key: '/dashboards', icon: <LineChartOutlined />, label: 'Dashboards' }, { key: '/dashboards', icon: <LineChartOutlined />, label: 'Dashboards' },
...(isAdmin ...(userIsAdmin
? [ ? [...adminItems, { key: '/settings', icon: <SettingOutlined />, label: 'Settings' }]
{ key: '/admin/sites', icon: <ApartmentOutlined />, label: 'Sites' },
{ key: '/settings', icon: <SettingOutlined />, label: 'Settings' },
]
: []), : []),
]; ];
@ -55,6 +58,7 @@ export function AppLayout() {
<Text strong style={{ color: '#fff', fontSize: 18 }}> <Text strong style={{ color: '#fff', fontSize: 18 }}>
{branding.applicationName} {branding.applicationName}
</Text> </Text>
{adminMode && <Tag color="gold" style={{ marginLeft: 8 }}>FLEET ADMIN</Tag>}
</Space> </Space>
<Space> <Space>
<Text style={{ color: '#cbd5e1' }}>{user?.displayName ?? user?.email}</Text> <Text style={{ color: '#cbd5e1' }}>{user?.displayName ?? user?.email}</Text>

View File

@ -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<AppInfoContextValue | undefined>(undefined);
export function AppInfoProvider({ children }: { children: ReactNode }) {
const { data, isLoading } = useQuery({
queryKey: ['app-info'],
queryFn: fetchAppInfo,
staleTime: 5 * 60_000,
});
const info = useMemo<AppRuntimeInfo>(() => data ?? FALLBACK, [data]);
const value = useMemo(
() => ({
info,
isAdmin: info.runMode === 'Admin',
isClient: info.runMode === 'Client',
loading: isLoading,
}),
[info, isLoading],
);
return <AppInfoContext.Provider value={value}>{children}</AppInfoContext.Provider>;
}
export function useAppInfo(): AppInfoContextValue {
const ctx = useContext(AppInfoContext);
if (!ctx) throw new Error('useAppInfo must be used inside AppInfoProvider');
return ctx;
}
export type { RunMode };

View File

@ -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<FormMode | null>(null);
const [formError, setFormError] = useState<string | null>(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<CustomerListItem> = [
{ title: 'Code', dataIndex: 'code', key: 'code', render: (v) => <Text strong>{v}</Text> },
{ title: 'Name', dataIndex: 'name', key: 'name' },
{
title: 'Status',
dataIndex: 'isActive',
key: 'isActive',
render: (v: boolean) => (v ? <Tag color="green">Active</Tag> : <Tag color="red">Disabled</Tag>),
},
{
title: 'Last push',
dataIndex: 'lastSeenAt',
key: 'lastSeenAt',
render: (v: string | null) =>
v ? new Date(v).toLocaleString() : <Text type="secondary">Never</Text>,
},
{
title: 'Token issued',
key: 'token',
render: (_, c) => {
const ts = c.tokenRotatedAt ?? c.tokenIssuedAt;
return new Date(ts).toLocaleDateString();
},
},
{
title: 'Actions',
key: 'actions',
render: (_, c) => (
<Space>
<Button size="small" icon={<EditOutlined />} onClick={() => { setFormError(null); setFormMode({ kind: 'edit', customer: c }); }}>
Edit
</Button>
<Tooltip title="Generate a new token. Old token stops working immediately.">
<Popconfirm
title={`Rotate token for ${c.code}?`}
description="The customer's push service will fail until their .env is updated with the new token."
okText="Rotate"
okButtonProps={{ danger: true }}
onConfirm={() => rotateMut.mutate(c.id)}
>
<Button size="small" icon={<ReloadOutlined />} loading={rotateMut.isPending && rotateMut.variables === c.id}>
Rotate token
</Button>
</Popconfirm>
</Tooltip>
<Popconfirm
title={`Delete ${c.code}?`}
description="All this customer's mirrored data (sites, devices, measurements, events) is removed."
okText="Delete"
okButtonProps={{ danger: true }}
onConfirm={() => deleteMut.mutate(c.id)}
>
<Button size="small" danger icon={<DeleteOutlined />}>Delete</Button>
</Popconfirm>
</Space>
),
},
];
return (
<Card
title="Customers"
extra={
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => { setFormError(null); setFormMode({ kind: 'create' }); }}
>
Register customer
</Button>
}
>
<Table<CustomerListItem>
rowKey="id"
columns={columns}
dataSource={customers}
loading={isLoading}
pagination={{ pageSize: 25 }}
/>
<CustomerFormModal
open={formMode !== null}
mode={formMode}
submitting={createMut.isPending || updateMut.isPending}
error={formError}
onClose={() => { setFormMode(null); setFormError(null); }}
onSubmit={handleSubmit}
/>
<TokenShownOnceModal
open={tokenModal.open}
customerCode={tokenModal.code}
token={tokenModal.token}
onClose={() => setTokenModal({ open: false, code: null, token: null })}
/>
</Card>
);
}
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.';
}

View File

@ -1,5 +1,7 @@
namespace Tau.Acuvim.Portal.Configuration; namespace Tau.Acuvim.Portal.Configuration;
public enum RunMode { Client, Admin }
public sealed class ApplicationOptions public sealed class ApplicationOptions
{ {
public const string SectionName = "Application"; public const string SectionName = "Application";
@ -7,6 +9,20 @@ public sealed class ApplicationOptions
public string Name { get; set; } = "Power Monitoring Portal"; public string Name { get; set; } = "Power Monitoring Portal";
public string Environment { get; set; } = "Development"; public string Environment { get; set; } = "Development";
public string PublicUrl { get; set; } = "http://localhost:8080"; 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 public sealed class DatabaseOptions

View File

@ -9,3 +9,13 @@ public static class Policies
{ {
public const string AdminOnly = "AdminOnly"; 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";
}

View File

@ -0,0 +1,3 @@
namespace Tau.Acuvim.Portal.DTOs;
public sealed record AppRuntimeInfoDto(string RunMode, string ApplicationName, string Version);

View File

@ -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);

View File

@ -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<ApplicationUser, IdentityRole, string>, IWhiteLabelStore
{
public AdminDbContext(DbContextOptions<AdminDbContext> options) : base(options) { }
public DbSet<WhiteLabelSettings> WhiteLabelSettings => Set<WhiteLabelSettings>();
public DbSet<Customer> Customers => Set<Customer>();
public DbSet<FleetSite> FleetSites => Set<FleetSite>();
public DbSet<FleetDevice> FleetDevices => Set<FleetDevice>();
public DbSet<FleetPowerMeasurement> FleetPowerMeasurements => Set<FleetPowerMeasurement>();
public DbSet<IngestEvent> IngestEvents => Set<IngestEvent>();
protected override void OnModelCreating(ModelBuilder builder)
{
base.OnModelCreating(builder);
builder.HasDefaultSchema("app");
SharedSchemaConfiguration.Apply(builder);
builder.Entity<Customer>(entity =>
{
entity.ToTable("Customers", schema: "fleet");
entity.HasIndex(x => x.Code).IsUnique();
entity.HasIndex(x => x.TokenHash).IsUnique();
});
builder.Entity<FleetSite>(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<FleetDevice>(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<FleetPowerMeasurement>(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<FleetDevice>()
.WithMany()
.HasForeignKey(x => new { x.CustomerId, x.DeviceId })
.OnDelete(DeleteBehavior.Cascade);
});
builder.Entity<IngestEvent>(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);
});
}
}

View File

@ -8,7 +8,9 @@ using Tau.Acuvim.Portal.Domain.Rates;
namespace Tau.Acuvim.Portal.Data; namespace Tau.Acuvim.Portal.Data;
public class AppDbContext : IdentityDbContext<ApplicationUser, IdentityRole, string> // Client-mode DbContext: identity + branding (shared) + monitoring + rates.
// Used when RunMode=Client.
public class AppDbContext : IdentityDbContext<ApplicationUser, IdentityRole, string>, IWhiteLabelStore
{ {
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { } public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
@ -25,21 +27,7 @@ public class AppDbContext : IdentityDbContext<ApplicationUser, IdentityRole, str
base.OnModelCreating(builder); base.OnModelCreating(builder);
builder.HasDefaultSchema("app"); builder.HasDefaultSchema("app");
SharedSchemaConfiguration.Apply(builder);
builder.Entity<ApplicationUser>().ToTable("AspNetUsers", schema: "identity");
builder.Entity<IdentityRole>().ToTable("AspNetRoles", schema: "identity");
builder.Entity<IdentityUserRole<string>>().ToTable("AspNetUserRoles", schema: "identity");
builder.Entity<IdentityUserClaim<string>>().ToTable("AspNetUserClaims", schema: "identity");
builder.Entity<IdentityRoleClaim<string>>().ToTable("AspNetRoleClaims", schema: "identity");
builder.Entity<IdentityUserLogin<string>>().ToTable("AspNetUserLogins", schema: "identity");
builder.Entity<IdentityUserToken<string>>().ToTable("AspNetUserTokens", schema: "identity");
builder.Entity<WhiteLabelSettings>(entity =>
{
entity.ToTable("WhiteLabelSettings", schema: "app");
entity.HasKey(x => x.Id);
entity.Property(x => x.Id).ValueGeneratedNever();
});
builder.Entity<Municipality>(entity => builder.Entity<Municipality>(entity =>
{ {

View File

@ -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> WhiteLabelSettings { get; }
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
}

View File

@ -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<ApplicationUser>().ToTable("AspNetUsers", schema: "identity");
builder.Entity<IdentityRole>().ToTable("AspNetRoles", schema: "identity");
builder.Entity<IdentityUserRole<string>>().ToTable("AspNetUserRoles", schema: "identity");
builder.Entity<IdentityUserClaim<string>>().ToTable("AspNetUserClaims", schema: "identity");
builder.Entity<IdentityRoleClaim<string>>().ToTable("AspNetRoleClaims", schema: "identity");
builder.Entity<IdentityUserLogin<string>>().ToTable("AspNetUserLogins", schema: "identity");
builder.Entity<IdentityUserToken<string>>().ToTable("AspNetUserTokens", schema: "identity");
builder.Entity<WhiteLabelSettings>(entity =>
{
entity.ToTable("WhiteLabelSettings", schema: "app");
entity.HasKey(x => x.Id);
entity.Property(x => x.Id).ValueGeneratedNever();
});
}
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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; }
}

View File

@ -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;
}

View File

@ -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; }
}

View File

@ -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;
}
}

View File

@ -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<ApplicationOptions> 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;
}
}

View File

@ -0,0 +1,596 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using Tau.Acuvim.Portal.Data;
#nullable disable
namespace Tau.Acuvim.Portal.Migrations.Admin
{
[DbContext(typeof(AdminDbContext))]
[Migration("20260518075811_InitialFleet")]
partial class InitialFleet
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("app")
.HasAnnotation("ProductVersion", "10.0.8")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
{
b.Property<string>("Id")
.HasColumnType("text");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("NormalizedName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.HasKey("Id");
b.HasIndex("NormalizedName")
.IsUnique()
.HasDatabaseName("RoleNameIndex");
b.ToTable("AspNetRoles", "identity");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<string>("RoleId")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("RoleId");
b.ToTable("AspNetRoleClaims", "identity");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AspNetUserClaims", "identity");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("ProviderKey")
.HasColumnType("text");
b.Property<string>("ProviderDisplayName")
.HasColumnType("text");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("text");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("AspNetUserLogins", "identity");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("text");
b.Property<string>("RoleId")
.HasColumnType("text");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("AspNetUserRoles", "identity");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("text");
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("Name")
.HasColumnType("text");
b.Property<string>("Value")
.HasColumnType("text");
b.HasKey("UserId", "LoginProvider", "Name");
b.ToTable("AspNetUserTokens", "identity");
});
modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Branding.WhiteLabelSettings", b =>
{
b.Property<int>("Id")
.HasColumnType("integer");
b.Property<string>("AccentColor")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<string>("ApplicationName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("FooterText")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<string>("LogoUrl")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<string>("PrimaryColor")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<string>("SecondaryColor")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.ToTable("WhiteLabelSettings", "app");
});
modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Fleet.Customer", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("FirstSeenAt")
.HasColumnType("timestamp with time zone");
b.Property<bool>("IsActive")
.HasColumnType("boolean");
b.Property<DateTime?>("LastSeenAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("TokenHash")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<DateTime>("TokenIssuedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("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<Guid>("CustomerId")
.HasColumnType("uuid");
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<string>("Description")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<string>("ExternalId")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<bool>("IsActive")
.HasColumnType("boolean");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<DateTime>("ReceivedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("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<DateTime>("Time")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("CustomerId")
.HasColumnType("uuid");
b.Property<Guid>("DeviceId")
.HasColumnType("uuid");
b.Property<double>("ActivePowerKw")
.HasColumnType("double precision");
b.Property<double?>("ApparentPowerKva")
.HasColumnType("double precision");
b.Property<double?>("EnergyExportedKwh")
.HasColumnType("double precision");
b.Property<double?>("EnergyImportedKwh")
.HasColumnType("double precision");
b.Property<double?>("FrequencyHz")
.HasColumnType("double precision");
b.Property<double?>("PowerFactor")
.HasColumnType("double precision");
b.Property<double?>("ReactivePowerKvar")
.HasColumnType("double precision");
b.Property<string>("Source")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<double?>("VoltageV")
.HasColumnType("double precision");
b.HasKey("Time", "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<Guid>("CustomerId")
.HasColumnType("uuid");
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<string>("Address")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<bool>("IsActive")
.HasColumnType("boolean");
b.Property<int?>("LocalMunicipalityId")
.HasColumnType("integer");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<DateTime>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<int>("BatchBytes")
.HasColumnType("integer");
b.Property<string>("BatchType")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<string>("ClientHwm")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<Guid>("CustomerId")
.HasColumnType("uuid");
b.Property<string>("Error")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<DateTime>("ReceivedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("RowsAccepted")
.HasColumnType("integer");
b.Property<int>("RowsRejected")
.HasColumnType("integer");
b.Property<TimeSpan?>("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<string>("Id")
.HasColumnType("text");
b.Property<int>("AccessFailedCount")
.HasColumnType("integer");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<bool>("EmailConfirmed")
.HasColumnType("boolean");
b.Property<bool>("IsActive")
.HasColumnType("boolean");
b.Property<bool>("LockoutEnabled")
.HasColumnType("boolean");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("timestamp with time zone");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("PasswordHash")
.HasColumnType("text");
b.Property<string>("PhoneNumber")
.HasColumnType("text");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("boolean");
b.Property<string>("SecurityStamp")
.HasColumnType("text");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("boolean");
b.Property<string>("UserName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.HasKey("Id");
b.HasIndex("NormalizedEmail")
.HasDatabaseName("EmailIndex");
b.HasIndex("NormalizedUserName")
.IsUnique()
.HasDatabaseName("UserNameIndex");
b.ToTable("AspNetUsers", "identity");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.HasOne("Tau.Acuvim.Portal.Domain.Identity.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.HasOne("Tau.Acuvim.Portal.Domain.Identity.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Tau.Acuvim.Portal.Domain.Identity.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.HasOne("Tau.Acuvim.Portal.Domain.Identity.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Tau.Acuvim.Portal.Domain.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
}
}
}

View File

@ -0,0 +1,483 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Tau.Acuvim.Portal.Migrations.Admin
{
/// <inheritdoc />
public partial class InitialFleet : Migration
{
/// <inheritdoc />
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<string>(type: "text", nullable: false),
Name = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
NormalizedName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
ConcurrencyStamp = table.Column<string>(type: "text", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetRoles", x => x.Id);
});
migrationBuilder.CreateTable(
name: "AspNetUsers",
schema: "identity",
columns: table => new
{
Id = table.Column<string>(type: "text", nullable: false),
DisplayName = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
IsActive = table.Column<bool>(type: "boolean", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UserName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
NormalizedUserName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
Email = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
NormalizedEmail = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
EmailConfirmed = table.Column<bool>(type: "boolean", nullable: false),
PasswordHash = table.Column<string>(type: "text", nullable: true),
SecurityStamp = table.Column<string>(type: "text", nullable: true),
ConcurrencyStamp = table.Column<string>(type: "text", nullable: true),
PhoneNumber = table.Column<string>(type: "text", nullable: true),
PhoneNumberConfirmed = table.Column<bool>(type: "boolean", nullable: false),
TwoFactorEnabled = table.Column<bool>(type: "boolean", nullable: false),
LockoutEnd = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
LockoutEnabled = table.Column<bool>(type: "boolean", nullable: false),
AccessFailedCount = table.Column<int>(type: "integer", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetUsers", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Customers",
schema: "fleet",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Code = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
Name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
TokenHash = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
TokenIssuedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
TokenRotatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
IsActive = table.Column<bool>(type: "boolean", nullable: false),
FirstSeenAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
LastSeenAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
CreatedAt = table.Column<DateTime>(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<int>(type: "integer", nullable: false),
ApplicationName = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
LogoUrl = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: false),
PrimaryColor = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
SecondaryColor = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
AccentColor = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
FooterText = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: false),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_WhiteLabelSettings", x => x.Id);
});
migrationBuilder.CreateTable(
name: "AspNetRoleClaims",
schema: "identity",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
RoleId = table.Column<string>(type: "text", nullable: false),
ClaimType = table.Column<string>(type: "text", nullable: true),
ClaimValue = table.Column<string>(type: "text", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetRoleClaims", x => x.Id);
table.ForeignKey(
name: "FK_AspNetRoleClaims_AspNetRoles_RoleId",
column: x => x.RoleId,
principalSchema: "identity",
principalTable: "AspNetRoles",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "AspNetUserClaims",
schema: "identity",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
UserId = table.Column<string>(type: "text", nullable: false),
ClaimType = table.Column<string>(type: "text", nullable: true),
ClaimValue = table.Column<string>(type: "text", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetUserClaims", x => x.Id);
table.ForeignKey(
name: "FK_AspNetUserClaims_AspNetUsers_UserId",
column: x => x.UserId,
principalSchema: "identity",
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "AspNetUserLogins",
schema: "identity",
columns: table => new
{
LoginProvider = table.Column<string>(type: "text", nullable: false),
ProviderKey = table.Column<string>(type: "text", nullable: false),
ProviderDisplayName = table.Column<string>(type: "text", nullable: true),
UserId = table.Column<string>(type: "text", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetUserLogins", x => new { x.LoginProvider, x.ProviderKey });
table.ForeignKey(
name: "FK_AspNetUserLogins_AspNetUsers_UserId",
column: x => x.UserId,
principalSchema: "identity",
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "AspNetUserRoles",
schema: "identity",
columns: table => new
{
UserId = table.Column<string>(type: "text", nullable: false),
RoleId = table.Column<string>(type: "text", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetUserRoles", x => new { x.UserId, x.RoleId });
table.ForeignKey(
name: "FK_AspNetUserRoles_AspNetRoles_RoleId",
column: x => x.RoleId,
principalSchema: "identity",
principalTable: "AspNetRoles",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_AspNetUserRoles_AspNetUsers_UserId",
column: x => x.UserId,
principalSchema: "identity",
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "AspNetUserTokens",
schema: "identity",
columns: table => new
{
UserId = table.Column<string>(type: "text", nullable: false),
LoginProvider = table.Column<string>(type: "text", nullable: false),
Name = table.Column<string>(type: "text", nullable: false),
Value = table.Column<string>(type: "text", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetUserTokens", x => new { x.UserId, x.LoginProvider, x.Name });
table.ForeignKey(
name: "FK_AspNetUserTokens_AspNetUsers_UserId",
column: x => x.UserId,
principalSchema: "identity",
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "IngestEvents",
schema: "fleet",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
CustomerId = table.Column<Guid>(type: "uuid", nullable: false),
ReceivedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
BatchType = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
RowsAccepted = table.Column<int>(type: "integer", nullable: false),
RowsRejected = table.Column<int>(type: "integer", nullable: false),
BatchBytes = table.Column<int>(type: "integer", nullable: false),
ClientHwm = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: true),
TimeSpread = table.Column<TimeSpan>(type: "interval", nullable: true),
Error = table.Column<string>(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<Guid>(type: "uuid", nullable: false),
CustomerId = table.Column<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
Address = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
LocalMunicipalityId = table.Column<int>(type: "integer", nullable: true),
IsActive = table.Column<bool>(type: "boolean", nullable: false),
ReceivedAt = table.Column<DateTime>(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<Guid>(type: "uuid", nullable: false),
CustomerId = table.Column<Guid>(type: "uuid", nullable: false),
SiteId = table.Column<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
ExternalId = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
Description = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
IsActive = table.Column<bool>(type: "boolean", nullable: false),
ReceivedAt = table.Column<DateTime>(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<DateTime>(type: "timestamp with time zone", nullable: false),
CustomerId = table.Column<Guid>(type: "uuid", nullable: false),
DeviceId = table.Column<Guid>(type: "uuid", nullable: false),
ActivePowerKw = table.Column<double>(type: "double precision", nullable: false),
ReactivePowerKvar = table.Column<double>(type: "double precision", nullable: true),
ApparentPowerKva = table.Column<double>(type: "double precision", nullable: true),
PowerFactor = table.Column<double>(type: "double precision", nullable: true),
VoltageV = table.Column<double>(type: "double precision", nullable: true),
FrequencyHz = table.Column<double>(type: "double precision", nullable: true),
EnergyImportedKwh = table.Column<double>(type: "double precision", nullable: true),
EnergyExportedKwh = table.Column<double>(type: "double precision", nullable: true),
Source = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_PowerMeasurements", x => new { x.Time, x.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 });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "AspNetRoleClaims",
schema: "identity");
migrationBuilder.DropTable(
name: "AspNetUserClaims",
schema: "identity");
migrationBuilder.DropTable(
name: "AspNetUserLogins",
schema: "identity");
migrationBuilder.DropTable(
name: "AspNetUserRoles",
schema: "identity");
migrationBuilder.DropTable(
name: "AspNetUserTokens",
schema: "identity");
migrationBuilder.DropTable(
name: "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");
}
}
}

View File

@ -0,0 +1,593 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using Tau.Acuvim.Portal.Data;
#nullable disable
namespace Tau.Acuvim.Portal.Migrations.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<string>("Id")
.HasColumnType("text");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("NormalizedName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.HasKey("Id");
b.HasIndex("NormalizedName")
.IsUnique()
.HasDatabaseName("RoleNameIndex");
b.ToTable("AspNetRoles", "identity");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<string>("RoleId")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("RoleId");
b.ToTable("AspNetRoleClaims", "identity");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AspNetUserClaims", "identity");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("ProviderKey")
.HasColumnType("text");
b.Property<string>("ProviderDisplayName")
.HasColumnType("text");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("text");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("AspNetUserLogins", "identity");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("text");
b.Property<string>("RoleId")
.HasColumnType("text");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("AspNetUserRoles", "identity");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("text");
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("Name")
.HasColumnType("text");
b.Property<string>("Value")
.HasColumnType("text");
b.HasKey("UserId", "LoginProvider", "Name");
b.ToTable("AspNetUserTokens", "identity");
});
modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Branding.WhiteLabelSettings", b =>
{
b.Property<int>("Id")
.HasColumnType("integer");
b.Property<string>("AccentColor")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<string>("ApplicationName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("FooterText")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<string>("LogoUrl")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<string>("PrimaryColor")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<string>("SecondaryColor")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.ToTable("WhiteLabelSettings", "app");
});
modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Fleet.Customer", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("FirstSeenAt")
.HasColumnType("timestamp with time zone");
b.Property<bool>("IsActive")
.HasColumnType("boolean");
b.Property<DateTime?>("LastSeenAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("TokenHash")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<DateTime>("TokenIssuedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("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<Guid>("CustomerId")
.HasColumnType("uuid");
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<string>("Description")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<string>("ExternalId")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<bool>("IsActive")
.HasColumnType("boolean");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<DateTime>("ReceivedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("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<DateTime>("Time")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("CustomerId")
.HasColumnType("uuid");
b.Property<Guid>("DeviceId")
.HasColumnType("uuid");
b.Property<double>("ActivePowerKw")
.HasColumnType("double precision");
b.Property<double?>("ApparentPowerKva")
.HasColumnType("double precision");
b.Property<double?>("EnergyExportedKwh")
.HasColumnType("double precision");
b.Property<double?>("EnergyImportedKwh")
.HasColumnType("double precision");
b.Property<double?>("FrequencyHz")
.HasColumnType("double precision");
b.Property<double?>("PowerFactor")
.HasColumnType("double precision");
b.Property<double?>("ReactivePowerKvar")
.HasColumnType("double precision");
b.Property<string>("Source")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<double?>("VoltageV")
.HasColumnType("double precision");
b.HasKey("Time", "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<Guid>("CustomerId")
.HasColumnType("uuid");
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<string>("Address")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<bool>("IsActive")
.HasColumnType("boolean");
b.Property<int?>("LocalMunicipalityId")
.HasColumnType("integer");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<DateTime>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<int>("BatchBytes")
.HasColumnType("integer");
b.Property<string>("BatchType")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<string>("ClientHwm")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<Guid>("CustomerId")
.HasColumnType("uuid");
b.Property<string>("Error")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<DateTime>("ReceivedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("RowsAccepted")
.HasColumnType("integer");
b.Property<int>("RowsRejected")
.HasColumnType("integer");
b.Property<TimeSpan?>("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<string>("Id")
.HasColumnType("text");
b.Property<int>("AccessFailedCount")
.HasColumnType("integer");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<bool>("EmailConfirmed")
.HasColumnType("boolean");
b.Property<bool>("IsActive")
.HasColumnType("boolean");
b.Property<bool>("LockoutEnabled")
.HasColumnType("boolean");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("timestamp with time zone");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("PasswordHash")
.HasColumnType("text");
b.Property<string>("PhoneNumber")
.HasColumnType("text");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("boolean");
b.Property<string>("SecurityStamp")
.HasColumnType("text");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("boolean");
b.Property<string>("UserName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.HasKey("Id");
b.HasIndex("NormalizedEmail")
.HasDatabaseName("EmailIndex");
b.HasIndex("NormalizedUserName")
.IsUnique()
.HasDatabaseName("UserNameIndex");
b.ToTable("AspNetUsers", "identity");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.HasOne("Tau.Acuvim.Portal.Domain.Identity.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.HasOne("Tau.Acuvim.Portal.Domain.Identity.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Tau.Acuvim.Portal.Domain.Identity.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.HasOne("Tau.Acuvim.Portal.Domain.Identity.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Tau.Acuvim.Portal.Domain.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
}
}
}

View File

@ -38,7 +38,10 @@ try
builder.Services.Configure<WhiteLabelOptions>(builder.Configuration.GetSection(WhiteLabelOptions.SectionName)); builder.Services.Configure<WhiteLabelOptions>(builder.Configuration.GetSection(WhiteLabelOptions.SectionName));
builder.Services.Configure<AuthenticationOptions>(builder.Configuration.GetSection(AuthenticationOptions.SectionName)); builder.Services.Configure<AuthenticationOptions>(builder.Configuration.GetSection(AuthenticationOptions.SectionName));
builder.Services.Configure<MonitoringOptions>(builder.Configuration.GetSection(MonitoringOptions.SectionName)); builder.Services.Configure<MonitoringOptions>(builder.Configuration.GetSection(MonitoringOptions.SectionName));
builder.Services.Configure<FleetIngestOptions>(builder.Configuration.GetSection(FleetIngestOptions.SectionName));
var applicationOptions = builder.Configuration.GetSection(ApplicationOptions.SectionName).Get<ApplicationOptions>()
?? new ApplicationOptions();
var authOptions = builder.Configuration.GetSection(AuthenticationOptions.SectionName).Get<AuthenticationOptions>() var authOptions = builder.Configuration.GetSection(AuthenticationOptions.SectionName).Get<AuthenticationOptions>()
?? new AuthenticationOptions(); ?? new AuthenticationOptions();
var databaseOptions = builder.Configuration.GetSection(DatabaseOptions.SectionName).Get<DatabaseOptions>() var databaseOptions = builder.Configuration.GetSection(DatabaseOptions.SectionName).Get<DatabaseOptions>()
@ -47,29 +50,38 @@ try
?? new TimescaleDbOptions(); ?? new TimescaleDbOptions();
var whiteLabelOptions = builder.Configuration.GetSection(WhiteLabelOptions.SectionName).Get<WhiteLabelOptions>() var whiteLabelOptions = builder.Configuration.GetSection(WhiteLabelOptions.SectionName).Get<WhiteLabelOptions>()
?? new WhiteLabelOptions(); ?? new WhiteLabelOptions();
var fleetIngestOptions = builder.Configuration.GetSection(FleetIngestOptions.SectionName).Get<FleetIngestOptions>()
?? new FleetIngestOptions();
RunModeGuards.ValidateConfig(applicationOptions, databaseOptions, fleetIngestOptions);
var resolution = ConnectionStringResolver.Resolve(databaseOptions, timescaleOptions, builder.Environment); 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.AddSingleton(new DatabaseResolutionInfo(resolution.Source));
builder.Services.AddDbContext<AppDbContext>(options => // ──────────────────────────────────────────────────────────────────────
options.UseNpgsql(resolution.ConnectionString)); // DbContext + Identity registration — branches on RunMode.
// Only one context is registered; the other never resolves.
builder.Services // ──────────────────────────────────────────────────────────────────────
.AddIdentity<ApplicationUser, IdentityRole>(options => if (applicationOptions.RunMode == RunMode.Client)
{ {
options.Password.RequireDigit = true; builder.Services.AddDbContext<AppDbContext>(options => options.UseNpgsql(resolution.ConnectionString));
options.Password.RequireLowercase = true; builder.Services.AddScoped<IWhiteLabelStore>(sp => sp.GetRequiredService<AppDbContext>());
options.Password.RequireUppercase = true; builder.Services
options.Password.RequireNonAlphanumeric = false; .AddIdentity<ApplicationUser, IdentityRole>(ConfigureIdentity(authOptions))
options.Password.RequiredLength = 8; .AddEntityFrameworkStores<AppDbContext>()
options.User.RequireUniqueEmail = true; .AddDefaultTokenProviders();
options.SignIn.RequireConfirmedEmail = authOptions.RequireConfirmedEmail; }
options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(15); else
options.Lockout.MaxFailedAccessAttempts = 5; {
}) builder.Services.AddDbContext<AdminDbContext>(options => options.UseNpgsql(resolution.ConnectionString));
.AddEntityFrameworkStores<AppDbContext>() builder.Services.AddScoped<IWhiteLabelStore>(sp => sp.GetRequiredService<AdminDbContext>());
.AddDefaultTokenProviders(); builder.Services
.AddIdentity<ApplicationUser, IdentityRole>(ConfigureIdentity(authOptions))
.AddEntityFrameworkStores<AdminDbContext>()
.AddDefaultTokenProviders();
}
builder.Services.ConfigureApplicationCookie(options => builder.Services.ConfigureApplicationCookie(options =>
{ {
@ -98,10 +110,6 @@ try
options.AddPolicy(Policies.AdminOnly, policy => policy.RequireRole(Roles.Admin)); 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"; var keyRingPath = builder.Configuration["DataProtection:KeyRing"] ?? "/data/keys";
if (Directory.Exists(keyRingPath)) if (Directory.Exists(keyRingPath))
{ {
@ -110,23 +118,34 @@ try
.SetApplicationName("Tau.Acuvim.Portal"); .SetApplicationName("Tau.Acuvim.Portal");
} }
// ──────────────────────────────────────────────────────────────────────
// Service registration — split by mode.
// ──────────────────────────────────────────────────────────────────────
builder.Services.AddScoped<BrandingService>(); builder.Services.AddScoped<BrandingService>();
builder.Services.AddScoped<RateService>();
builder.Services.AddSingleton<CostCalculator>();
builder.Services.AddScoped<IdentityBootstrapper>(); builder.Services.AddScoped<IdentityBootstrapper>();
builder.Services.AddScoped<TimescaleBootstrapper>();
builder.Services.AddScoped<MeasurementIngestService>();
builder.Services.AddScoped<MeasurementQueryService>();
builder.Services.AddSingleton<GrafanaService>(); builder.Services.AddSingleton<GrafanaService>();
builder.Services.AddScoped<ConfigOverviewService>(); builder.Services.AddScoped<ConfigOverviewService>();
if (applicationOptions.RunMode == RunMode.Client)
{
builder.Services.AddScoped<RateService>();
builder.Services.AddSingleton<CostCalculator>();
builder.Services.AddScoped<TimescaleBootstrapper>();
builder.Services.AddScoped<MeasurementIngestService>();
builder.Services.AddScoped<MeasurementQueryService>();
}
else
{
builder.Services.AddScoped<CustomerService>();
}
builder.Services.AddHealthChecks() builder.Services.AddHealthChecks()
.AddNpgSql(resolution.ConnectionString, name: "timescaledb", tags: new[] { "ready" }); .AddNpgSql(resolution.ConnectionString, name: "database", tags: new[] { "ready" });
builder.Services.AddEndpointsApiExplorer(); builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(c => 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 => builder.Services.AddCors(options =>
@ -151,14 +170,26 @@ try
} }
} }
await RunModeGuards.ValidateDatabaseShapeAsync(app.Services, applicationOptions.RunMode);
if (databaseOptions.MigrateOnStartup) if (databaseOptions.MigrateOnStartup)
{ {
using var scope = app.Services.CreateScope(); using var scope = app.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await db.Database.MigrateAsync();
var timescale = scope.ServiceProvider.GetRequiredService<TimescaleBootstrapper>(); if (applicationOptions.RunMode == RunMode.Client)
await timescale.EnsureHypertablesAsync(); {
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await db.Database.MigrateAsync();
var timescale = scope.ServiceProvider.GetRequiredService<TimescaleBootstrapper>();
await timescale.EnsureHypertablesAsync();
}
else
{
var db = scope.ServiceProvider.GetRequiredService<AdminDbContext>();
await db.Database.MigrateAsync();
// Admin-side hypertable + continuous aggregates land in Phase 14.
}
var bootstrapper = scope.ServiceProvider.GetRequiredService<IdentityBootstrapper>(); var bootstrapper = scope.ServiceProvider.GetRequiredService<IdentityBootstrapper>();
await bootstrapper.SeedAsync(); await bootstrapper.SeedAsync();
@ -180,7 +211,7 @@ try
{ {
app.UseCors("DevSpa"); app.UseCors("DevSpa");
app.UseSwagger(); 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 else
{ {
@ -199,15 +230,28 @@ try
app.UseAuthentication(); app.UseAuthentication();
app.UseAuthorization(); app.UseAuthorization();
// ──────────────────────────────────────────────────────────────────────
// Endpoint mapping — shared first, then mode-specific.
// ──────────────────────────────────────────────────────────────────────
app.MapAuthEndpoints(); app.MapAuthEndpoints();
app.MapAdminUserEndpoints(); app.MapAdminUserEndpoints();
app.MapBrandingEndpoints(); app.MapBrandingEndpoints();
app.MapRatesEndpoints();
app.MapAdminRatesEndpoints();
app.MapSitesEndpoints();
app.MapMeasurementsEndpoints();
app.MapGrafanaEndpoints(); app.MapGrafanaEndpoints();
app.MapAdminConfigEndpoints(); 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 app.MapHealthChecks("/health", new Microsoft.AspNetCore.Diagnostics.HealthChecks.HealthCheckOptions
{ {
Predicate = _ => false Predicate = _ => false
@ -229,3 +273,16 @@ finally
{ {
Log.CloseAndFlush(); Log.CloseAndFlush();
} }
static Action<IdentityOptions> 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;
};

View File

@ -8,12 +8,12 @@ using Tau.Acuvim.Portal.DTOs;
namespace Tau.Acuvim.Portal.Services; namespace Tau.Acuvim.Portal.Services;
public sealed class BrandingService( public sealed class BrandingService(
AppDbContext db, IWhiteLabelStore store,
IOptions<WhiteLabelOptions> defaults) IOptions<WhiteLabelOptions> defaults)
{ {
public async Task<BrandingDto> GetAsync(CancellationToken ct = default) public async Task<BrandingDto> GetAsync(CancellationToken ct = default)
{ {
var row = await db.WhiteLabelSettings.AsNoTracking() var row = await store.WhiteLabelSettings.AsNoTracking()
.FirstOrDefaultAsync(x => x.Id == WhiteLabelSettings.SingletonId, ct); .FirstOrDefaultAsync(x => x.Id == WhiteLabelSettings.SingletonId, ct);
row ??= await SeedAsync(ct); row ??= await SeedAsync(ct);
return ToDto(row); return ToDto(row);
@ -21,7 +21,7 @@ public sealed class BrandingService(
public async Task<BrandingDto> UpdateAsync(UpdateBrandingRequest req, CancellationToken ct = default) public async Task<BrandingDto> UpdateAsync(UpdateBrandingRequest req, CancellationToken ct = default)
{ {
var row = await db.WhiteLabelSettings var row = await store.WhiteLabelSettings
.FirstOrDefaultAsync(x => x.Id == WhiteLabelSettings.SingletonId, ct) .FirstOrDefaultAsync(x => x.Id == WhiteLabelSettings.SingletonId, ct)
?? await SeedAsync(ct); ?? await SeedAsync(ct);
@ -33,24 +33,24 @@ public sealed class BrandingService(
if (req.LogoUrl is not null) row.LogoUrl = req.LogoUrl; if (req.LogoUrl is not null) row.LogoUrl = req.LogoUrl;
row.UpdatedAt = DateTime.UtcNow; row.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync(ct); await store.SaveChangesAsync(ct);
return ToDto(row); return ToDto(row);
} }
public async Task<string> SetLogoUrlAsync(string logoUrl, CancellationToken ct = default) public async Task<string> SetLogoUrlAsync(string logoUrl, CancellationToken ct = default)
{ {
var row = await db.WhiteLabelSettings var row = await store.WhiteLabelSettings
.FirstOrDefaultAsync(x => x.Id == WhiteLabelSettings.SingletonId, ct) .FirstOrDefaultAsync(x => x.Id == WhiteLabelSettings.SingletonId, ct)
?? await SeedAsync(ct); ?? await SeedAsync(ct);
row.LogoUrl = logoUrl; row.LogoUrl = logoUrl;
row.UpdatedAt = DateTime.UtcNow; row.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync(ct); await store.SaveChangesAsync(ct);
return logoUrl; return logoUrl;
} }
public async Task EnsureSeededAsync(CancellationToken ct = default) public async Task EnsureSeededAsync(CancellationToken ct = default)
{ {
var exists = await db.WhiteLabelSettings var exists = await store.WhiteLabelSettings
.AnyAsync(x => x.Id == WhiteLabelSettings.SingletonId, ct); .AnyAsync(x => x.Id == WhiteLabelSettings.SingletonId, ct);
if (!exists) await SeedAsync(ct); if (!exists) await SeedAsync(ct);
} }
@ -69,8 +69,8 @@ public sealed class BrandingService(
FooterText = d.FooterText, FooterText = d.FooterText,
UpdatedAt = DateTime.UtcNow UpdatedAt = DateTime.UtcNow
}; };
db.WhiteLabelSettings.Add(row); store.WhiteLabelSettings.Add(row);
await db.SaveChangesAsync(ct); await store.SaveChangesAsync(ct);
return row; return row;
} }

View File

@ -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<List<CustomerListItemDto>> ListAsync(CancellationToken ct = default) =>
await db.Customers
.OrderBy(c => c.Code)
.Select(c => ToDto(c))
.ToListAsync(ct);
public async Task<CustomerWithTokenDto> 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<bool> 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<CustomerWithTokenDto?> 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<bool> 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<Customer?> FindByTokenAsync(string token, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(token)) return Task.FromResult<Customer?>(null);
var hash = HashToken(token);
return db.Customers.FirstOrDefaultAsync(c => c.TokenHash == hash && c.IsActive, ct);
}
public static string GenerateToken()
{
Span<byte> bytes = stackalloc byte[32];
RandomNumberGenerator.Fill(bytes);
return Convert.ToHexString(bytes).ToLowerInvariant();
}
public static string HashToken(string token)
{
Span<byte> 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);
}

View File

@ -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<AppDbContext>();
if (db is null) return;
try
{
var hasFleet = await db.Database
.SqlQuery<int>($@"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<AdminDbContext>();
if (db is null) return;
try
{
var hasMonitoring = await db.Database
.SqlQuery<int>($@"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 */ }
}
}
}

View File

@ -2,7 +2,16 @@
"Application": { "Application": {
"Name": "Power Monitoring Portal", "Name": "Power Monitoring Portal",
"Environment": "Development", "Environment": "Development",
"PublicUrl": "http://localhost:8080" "PublicUrl": "http://localhost:8080",
"RunMode": "Client"
},
"FleetIngest": {
"Enabled": false,
"Url": "",
"Token": "",
"IntervalSeconds": 60,
"BatchSize": 5000,
"BatchMaxBytes": 1048576
}, },
"Database": { "Database": {
"Provider": "PostgreSQL", "Provider": "PostgreSQL",

View File

@ -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);
}
}

View File

@ -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<InvalidOperationException>(() =>
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<InvalidOperationException>(() =>
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<InvalidOperationException>(() =>
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);
}
}