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:
parent
880525b306
commit
2c618b776b
@ -32,6 +32,7 @@ A customer signs in to their own branded portal, sees their meters' live + histo
|
||||
|
||||
| Layer | Technology |
|
||||
|---|---|
|
||||
| Run modes | `RunMode=Client` (per-customer, default) or `RunMode=Admin` (fleet aggregation) — same binary, config-selected. See [docs/FLEET-DESIGN.md](./docs/FLEET-DESIGN.md). |
|
||||
| Backend | .NET 10 minimal API, EF Core 10, Npgsql, ASP.NET Core Identity, Serilog |
|
||||
| Frontend | React 18 + TypeScript + Vite, Ant Design 5, TanStack Query, react-router |
|
||||
| Database | TimescaleDB 2.17 on PostgreSQL 16 |
|
||||
@ -124,18 +125,31 @@ All configurable values are declared in `src/Tau.Acuvim.Portal/appsettings.templ
|
||||
- Docker Desktop
|
||||
- `dotnet-ef` tool: `dotnet tool install --global dotnet-ef`
|
||||
|
||||
### First-time: generate the initial migration
|
||||
### First-time: generate the initial migrations
|
||||
|
||||
Identity / branding / rates / monitoring entities are defined in code; the migration files themselves are generated artifacts. Run once:
|
||||
Two `DbContext` classes — one per `RunMode` — each with its own migration folder.
|
||||
|
||||
```powershell
|
||||
cd C:\AcuvimDev\Tau.Acuvim\portal
|
||||
|
||||
# Client (RunMode=Client, default) — identity + branding + monitoring + rates
|
||||
dotnet ef migrations add InitialCreate `
|
||||
--context AppDbContext `
|
||||
--project src/Tau.Acuvim.Portal/Tau.Acuvim.Portal.csproj `
|
||||
--output-dir Migrations
|
||||
|
||||
# Admin (RunMode=Admin) — identity + branding + fleet
|
||||
$env:Application__RunMode='Admin'
|
||||
$env:Database__ConnectionString='Host=localhost;Database=stub;Username=u;Password=p' # parsed only
|
||||
dotnet ef migrations add InitialFleet `
|
||||
--context AdminDbContext `
|
||||
--project src/Tau.Acuvim.Portal/Tau.Acuvim.Portal.csproj `
|
||||
--output-dir Migrations/Admin
|
||||
Remove-Item Env:Application__RunMode
|
||||
Remove-Item Env:Database__ConnectionString
|
||||
```
|
||||
|
||||
Commit the resulting `Migrations/` folder. From then on, `MigrateAsync` on startup applies whatever exists — no manual step at deploy time.
|
||||
Commit both `Migrations/` and `Migrations/Admin/`. `MigrateAsync` on startup applies whatever exists for the active context.
|
||||
|
||||
### Option A: full stack in Docker (recommended)
|
||||
|
||||
@ -367,3 +381,23 @@ See [TESTING.md](./TESTING.md) for the full manual integration scenario, fronten
|
||||
## Operations
|
||||
|
||||
For per-customer provisioning, secret rotation, backups, and health monitoring see [OPERATIONS.md](./OPERATIONS.md).
|
||||
|
||||
## Admin / Fleet mode
|
||||
|
||||
A second deployment of the same image — `RunMode=Admin`, separate DB — aggregates data from all customer stacks for a fleet-wide operator view. See [docs/FLEET-DESIGN.md](./docs/FLEET-DESIGN.md) for the full design.
|
||||
|
||||
**Phase 13 (this release):** the Admin stack runs, the Customers page registers customers and issues push tokens (shown once). Customer stacks pick up `RunMode=Client` + `FleetIngest__*` config but don't yet push — that lands in Phase 14.
|
||||
|
||||
**Spin up an Admin stack:**
|
||||
```powershell
|
||||
docker exec <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).
|
||||
|
||||
@ -2,6 +2,7 @@ import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { AuthProvider } from './hooks/useAuth';
|
||||
import { BrandingProvider } from './hooks/useBranding';
|
||||
import { AppInfoProvider } from './hooks/useAppInfo';
|
||||
import { ThemedRoot } from './components/ThemedRoot';
|
||||
import { RequireAuth } from './components/RequireAuth';
|
||||
import { RequireRole } from './components/RequireRole';
|
||||
@ -10,6 +11,7 @@ import { LoginPage } from './pages/LoginPage';
|
||||
import { DashboardPage } from './pages/DashboardPage';
|
||||
import { DashboardsPage } from './pages/DashboardsPage';
|
||||
import { AdminSitesPage } from './pages/AdminSitesPage';
|
||||
import { AdminCustomersPage } from './pages/AdminCustomersPage';
|
||||
import { SettingsPage } from './pages/SettingsPage';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
@ -19,46 +21,56 @@ const queryClient = new QueryClient({
|
||||
export default function App() {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrandingProvider>
|
||||
<ThemedRoot>
|
||||
<AuthProvider>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<AppLayout />
|
||||
</RequireAuth>
|
||||
}
|
||||
>
|
||||
<Route index element={<DashboardPage />} />
|
||||
<Route path="dashboards" element={<DashboardsPage />} />
|
||||
<AppInfoProvider>
|
||||
<BrandingProvider>
|
||||
<ThemedRoot>
|
||||
<AuthProvider>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route
|
||||
path="admin/sites"
|
||||
path="/"
|
||||
element={
|
||||
<RequireRole role="Admin">
|
||||
<AdminSitesPage />
|
||||
</RequireRole>
|
||||
<RequireAuth>
|
||||
<AppLayout />
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<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>
|
||||
>
|
||||
<Route index element={<DashboardPage />} />
|
||||
<Route path="dashboards" element={<DashboardsPage />} />
|
||||
<Route
|
||||
path="admin/sites"
|
||||
element={
|
||||
<RequireRole role="Admin">
|
||||
<AdminSitesPage />
|
||||
</RequireRole>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="admin/customers"
|
||||
element={
|
||||
<RequireRole role="Admin">
|
||||
<AdminCustomersPage />
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
14
portal/frontend/src/api/appInfo.ts
Normal file
14
portal/frontend/src/api/appInfo.ts
Normal 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;
|
||||
}
|
||||
51
portal/frontend/src/api/customers.ts
Normal file
51
portal/frontend/src/api/customers.ts
Normal 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}`);
|
||||
}
|
||||
@ -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: '3–50 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -1,11 +1,12 @@
|
||||
import { Layout, Menu, Button, Typography, Space } from 'antd';
|
||||
import { Layout, Menu, Button, Typography, Space, Tag } from 'antd';
|
||||
import {
|
||||
DashboardOutlined, SettingOutlined, LogoutOutlined,
|
||||
LineChartOutlined, ApartmentOutlined,
|
||||
LineChartOutlined, ApartmentOutlined, TeamOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../../hooks/useAuth';
|
||||
import { useBranding } from '../../hooks/useBranding';
|
||||
import { useAppInfo } from '../../hooks/useAppInfo';
|
||||
|
||||
const { Header, Sider, Content, Footer } = Layout;
|
||||
const { Text } = Typography;
|
||||
@ -13,23 +14,25 @@ const { Text } = Typography;
|
||||
export function AppLayout() {
|
||||
const { user, logout } = useAuth();
|
||||
const { branding } = useBranding();
|
||||
const { isAdmin: adminMode } = useAppInfo();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const isAdmin = user?.roles.includes('Admin') ?? false;
|
||||
const userIsAdmin = user?.roles.includes('Admin') ?? false;
|
||||
|
||||
const handleLogout = async () => {
|
||||
await logout();
|
||||
navigate('/login', { replace: true });
|
||||
};
|
||||
|
||||
const adminItems = adminMode
|
||||
? [{ key: '/admin/customers', icon: <TeamOutlined />, label: 'Customers' }]
|
||||
: [{ key: '/admin/sites', icon: <ApartmentOutlined />, label: 'Sites' }];
|
||||
|
||||
const items = [
|
||||
{ key: '/', icon: <DashboardOutlined />, label: 'Dashboard' },
|
||||
{ key: '/dashboards', icon: <LineChartOutlined />, label: 'Dashboards' },
|
||||
...(isAdmin
|
||||
? [
|
||||
{ key: '/admin/sites', icon: <ApartmentOutlined />, label: 'Sites' },
|
||||
{ key: '/settings', icon: <SettingOutlined />, label: 'Settings' },
|
||||
]
|
||||
...(userIsAdmin
|
||||
? [...adminItems, { key: '/settings', icon: <SettingOutlined />, label: 'Settings' }]
|
||||
: []),
|
||||
];
|
||||
|
||||
@ -55,6 +58,7 @@ export function AppLayout() {
|
||||
<Text strong style={{ color: '#fff', fontSize: 18 }}>
|
||||
{branding.applicationName}
|
||||
</Text>
|
||||
{adminMode && <Tag color="gold" style={{ marginLeft: 8 }}>FLEET ADMIN</Tag>}
|
||||
</Space>
|
||||
<Space>
|
||||
<Text style={{ color: '#cbd5e1' }}>{user?.displayName ?? user?.email}</Text>
|
||||
|
||||
48
portal/frontend/src/hooks/useAppInfo.tsx
Normal file
48
portal/frontend/src/hooks/useAppInfo.tsx
Normal 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 };
|
||||
183
portal/frontend/src/pages/AdminCustomersPage.tsx
Normal file
183
portal/frontend/src/pages/AdminCustomersPage.tsx
Normal 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.';
|
||||
}
|
||||
@ -1,5 +1,7 @@
|
||||
namespace Tau.Acuvim.Portal.Configuration;
|
||||
|
||||
public enum RunMode { Client, Admin }
|
||||
|
||||
public sealed class ApplicationOptions
|
||||
{
|
||||
public const string SectionName = "Application";
|
||||
@ -7,6 +9,20 @@ public sealed class ApplicationOptions
|
||||
public string Name { get; set; } = "Power Monitoring Portal";
|
||||
public string Environment { get; set; } = "Development";
|
||||
public string PublicUrl { get; set; } = "http://localhost:8080";
|
||||
public RunMode RunMode { get; set; } = RunMode.Client;
|
||||
}
|
||||
|
||||
public sealed class FleetIngestOptions
|
||||
{
|
||||
public const string SectionName = "FleetIngest";
|
||||
|
||||
// Push-side (Client mode) settings — Phase 14 wires the actual service.
|
||||
public bool Enabled { get; set; }
|
||||
public string Url { get; set; } = string.Empty;
|
||||
public string Token { get; set; } = string.Empty;
|
||||
public int IntervalSeconds { get; set; } = 60;
|
||||
public int BatchSize { get; set; } = 5000;
|
||||
public int BatchMaxBytes { get; set; } = 1_048_576;
|
||||
}
|
||||
|
||||
public sealed class DatabaseOptions
|
||||
|
||||
@ -9,3 +9,13 @@ public static class Policies
|
||||
{
|
||||
public const string AdminOnly = "AdminOnly";
|
||||
}
|
||||
|
||||
public static class FleetSchema
|
||||
{
|
||||
public const string Name = "fleet";
|
||||
public const string CustomersTable = "Customers";
|
||||
public const string SitesTable = "Sites";
|
||||
public const string DevicesTable = "Devices";
|
||||
public const string PowerMeasurementsTable = "PowerMeasurements";
|
||||
public const string IngestEventsTable = "IngestEvents";
|
||||
}
|
||||
|
||||
3
portal/src/Tau.Acuvim.Portal/DTOs/AppInfoDtos.cs
Normal file
3
portal/src/Tau.Acuvim.Portal/DTOs/AppInfoDtos.cs
Normal file
@ -0,0 +1,3 @@
|
||||
namespace Tau.Acuvim.Portal.DTOs;
|
||||
|
||||
public sealed record AppRuntimeInfoDto(string RunMode, string ApplicationName, string Version);
|
||||
19
portal/src/Tau.Acuvim.Portal/DTOs/CustomerDtos.cs
Normal file
19
portal/src/Tau.Acuvim.Portal/DTOs/CustomerDtos.cs
Normal 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);
|
||||
83
portal/src/Tau.Acuvim.Portal/Data/AdminDbContext.cs
Normal file
83
portal/src/Tau.Acuvim.Portal/Data/AdminDbContext.cs
Normal 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -8,7 +8,9 @@ using Tau.Acuvim.Portal.Domain.Rates;
|
||||
|
||||
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) { }
|
||||
|
||||
@ -25,21 +27,7 @@ public class AppDbContext : IdentityDbContext<ApplicationUser, IdentityRole, str
|
||||
base.OnModelCreating(builder);
|
||||
|
||||
builder.HasDefaultSchema("app");
|
||||
|
||||
builder.Entity<ApplicationUser>().ToTable("AspNetUsers", schema: "identity");
|
||||
builder.Entity<IdentityRole>().ToTable("AspNetRoles", schema: "identity");
|
||||
builder.Entity<IdentityUserRole<string>>().ToTable("AspNetUserRoles", schema: "identity");
|
||||
builder.Entity<IdentityUserClaim<string>>().ToTable("AspNetUserClaims", schema: "identity");
|
||||
builder.Entity<IdentityRoleClaim<string>>().ToTable("AspNetRoleClaims", schema: "identity");
|
||||
builder.Entity<IdentityUserLogin<string>>().ToTable("AspNetUserLogins", schema: "identity");
|
||||
builder.Entity<IdentityUserToken<string>>().ToTable("AspNetUserTokens", schema: "identity");
|
||||
|
||||
builder.Entity<WhiteLabelSettings>(entity =>
|
||||
{
|
||||
entity.ToTable("WhiteLabelSettings", schema: "app");
|
||||
entity.HasKey(x => x.Id);
|
||||
entity.Property(x => x.Id).ValueGeneratedNever();
|
||||
});
|
||||
SharedSchemaConfiguration.Apply(builder);
|
||||
|
||||
builder.Entity<Municipality>(entity =>
|
||||
{
|
||||
|
||||
13
portal/src/Tau.Acuvim.Portal/Data/IWhiteLabelStore.cs
Normal file
13
portal/src/Tau.Acuvim.Portal/Data/IWhiteLabelStore.cs
Normal 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);
|
||||
}
|
||||
@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
27
portal/src/Tau.Acuvim.Portal/Domain/Fleet/Customer.cs
Normal file
27
portal/src/Tau.Acuvim.Portal/Domain/Fleet/Customer.cs
Normal 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;
|
||||
}
|
||||
27
portal/src/Tau.Acuvim.Portal/Domain/Fleet/FleetDevice.cs
Normal file
27
portal/src/Tau.Acuvim.Portal/Domain/Fleet/FleetDevice.cs
Normal 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;
|
||||
}
|
||||
@ -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; }
|
||||
}
|
||||
23
portal/src/Tau.Acuvim.Portal/Domain/Fleet/FleetSite.cs
Normal file
23
portal/src/Tau.Acuvim.Portal/Domain/Fleet/FleetSite.cs
Normal 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;
|
||||
}
|
||||
28
portal/src/Tau.Acuvim.Portal/Domain/Fleet/IngestEvent.cs
Normal file
28
portal/src/Tau.Acuvim.Portal/Domain/Fleet/IngestEvent.cs
Normal 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; }
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
24
portal/src/Tau.Acuvim.Portal/Endpoints/AppInfoEndpoints.cs
Normal file
24
portal/src/Tau.Acuvim.Portal/Endpoints/AppInfoEndpoints.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
596
portal/src/Tau.Acuvim.Portal/Migrations/Admin/20260518075811_InitialFleet.Designer.cs
generated
Normal file
596
portal/src/Tau.Acuvim.Portal/Migrations/Admin/20260518075811_InitialFleet.Designer.cs
generated
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -38,7 +38,10 @@ try
|
||||
builder.Services.Configure<WhiteLabelOptions>(builder.Configuration.GetSection(WhiteLabelOptions.SectionName));
|
||||
builder.Services.Configure<AuthenticationOptions>(builder.Configuration.GetSection(AuthenticationOptions.SectionName));
|
||||
builder.Services.Configure<MonitoringOptions>(builder.Configuration.GetSection(MonitoringOptions.SectionName));
|
||||
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>()
|
||||
?? new AuthenticationOptions();
|
||||
var databaseOptions = builder.Configuration.GetSection(DatabaseOptions.SectionName).Get<DatabaseOptions>()
|
||||
@ -47,29 +50,38 @@ try
|
||||
?? new TimescaleDbOptions();
|
||||
var whiteLabelOptions = builder.Configuration.GetSection(WhiteLabelOptions.SectionName).Get<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);
|
||||
Log.Information("Database connection resolved via {Source}", resolution.Source);
|
||||
Log.Information("RunMode={RunMode}, database connection resolved via {Source}",
|
||||
applicationOptions.RunMode, resolution.Source);
|
||||
builder.Services.AddSingleton(new DatabaseResolutionInfo(resolution.Source));
|
||||
|
||||
builder.Services.AddDbContext<AppDbContext>(options =>
|
||||
options.UseNpgsql(resolution.ConnectionString));
|
||||
|
||||
builder.Services
|
||||
.AddIdentity<ApplicationUser, IdentityRole>(options =>
|
||||
{
|
||||
options.Password.RequireDigit = true;
|
||||
options.Password.RequireLowercase = true;
|
||||
options.Password.RequireUppercase = true;
|
||||
options.Password.RequireNonAlphanumeric = false;
|
||||
options.Password.RequiredLength = 8;
|
||||
options.User.RequireUniqueEmail = true;
|
||||
options.SignIn.RequireConfirmedEmail = authOptions.RequireConfirmedEmail;
|
||||
options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(15);
|
||||
options.Lockout.MaxFailedAccessAttempts = 5;
|
||||
})
|
||||
.AddEntityFrameworkStores<AppDbContext>()
|
||||
.AddDefaultTokenProviders();
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// DbContext + Identity registration — branches on RunMode.
|
||||
// Only one context is registered; the other never resolves.
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
if (applicationOptions.RunMode == RunMode.Client)
|
||||
{
|
||||
builder.Services.AddDbContext<AppDbContext>(options => options.UseNpgsql(resolution.ConnectionString));
|
||||
builder.Services.AddScoped<IWhiteLabelStore>(sp => sp.GetRequiredService<AppDbContext>());
|
||||
builder.Services
|
||||
.AddIdentity<ApplicationUser, IdentityRole>(ConfigureIdentity(authOptions))
|
||||
.AddEntityFrameworkStores<AppDbContext>()
|
||||
.AddDefaultTokenProviders();
|
||||
}
|
||||
else
|
||||
{
|
||||
builder.Services.AddDbContext<AdminDbContext>(options => options.UseNpgsql(resolution.ConnectionString));
|
||||
builder.Services.AddScoped<IWhiteLabelStore>(sp => sp.GetRequiredService<AdminDbContext>());
|
||||
builder.Services
|
||||
.AddIdentity<ApplicationUser, IdentityRole>(ConfigureIdentity(authOptions))
|
||||
.AddEntityFrameworkStores<AdminDbContext>()
|
||||
.AddDefaultTokenProviders();
|
||||
}
|
||||
|
||||
builder.Services.ConfigureApplicationCookie(options =>
|
||||
{
|
||||
@ -98,10 +110,6 @@ try
|
||||
options.AddPolicy(Policies.AdminOnly, policy => policy.RequireRole(Roles.Admin));
|
||||
});
|
||||
|
||||
// Persist DataProtection keys to a mounted volume so auth cookies survive container restarts.
|
||||
// /data/keys is created + chowned to the app user in the Dockerfile; the dev and prod
|
||||
// compose files mount portal-keys there. Skip silently if the path doesn't exist
|
||||
// (local `dotnet run` outside Docker — keys go to the default user-profile location).
|
||||
var keyRingPath = builder.Configuration["DataProtection:KeyRing"] ?? "/data/keys";
|
||||
if (Directory.Exists(keyRingPath))
|
||||
{
|
||||
@ -110,23 +118,34 @@ try
|
||||
.SetApplicationName("Tau.Acuvim.Portal");
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// Service registration — split by mode.
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
builder.Services.AddScoped<BrandingService>();
|
||||
builder.Services.AddScoped<RateService>();
|
||||
builder.Services.AddSingleton<CostCalculator>();
|
||||
builder.Services.AddScoped<IdentityBootstrapper>();
|
||||
builder.Services.AddScoped<TimescaleBootstrapper>();
|
||||
builder.Services.AddScoped<MeasurementIngestService>();
|
||||
builder.Services.AddScoped<MeasurementQueryService>();
|
||||
builder.Services.AddSingleton<GrafanaService>();
|
||||
builder.Services.AddScoped<ConfigOverviewService>();
|
||||
|
||||
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()
|
||||
.AddNpgSql(resolution.ConnectionString, name: "timescaledb", tags: new[] { "ready" });
|
||||
.AddNpgSql(resolution.ConnectionString, name: "database", tags: new[] { "ready" });
|
||||
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddSwaggerGen(c =>
|
||||
{
|
||||
c.SwaggerDoc("v1", new() { Title = "Tau Acuvim Portal API", Version = "v1" });
|
||||
c.SwaggerDoc("v1", new() { Title = $"Tau Acuvim Portal API ({applicationOptions.RunMode})", Version = "v1" });
|
||||
});
|
||||
|
||||
builder.Services.AddCors(options =>
|
||||
@ -151,14 +170,26 @@ try
|
||||
}
|
||||
}
|
||||
|
||||
await RunModeGuards.ValidateDatabaseShapeAsync(app.Services, applicationOptions.RunMode);
|
||||
|
||||
if (databaseOptions.MigrateOnStartup)
|
||||
{
|
||||
using var scope = app.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
await db.Database.MigrateAsync();
|
||||
|
||||
var timescale = scope.ServiceProvider.GetRequiredService<TimescaleBootstrapper>();
|
||||
await timescale.EnsureHypertablesAsync();
|
||||
if (applicationOptions.RunMode == RunMode.Client)
|
||||
{
|
||||
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>();
|
||||
await bootstrapper.SeedAsync();
|
||||
@ -180,7 +211,7 @@ try
|
||||
{
|
||||
app.UseCors("DevSpa");
|
||||
app.UseSwagger();
|
||||
app.UseSwaggerUI(c => c.SwaggerEndpoint("/swagger/v1/swagger.json", "Tau Acuvim Portal API"));
|
||||
app.UseSwaggerUI(c => c.SwaggerEndpoint("/swagger/v1/swagger.json", $"Tau Acuvim Portal API ({applicationOptions.RunMode})"));
|
||||
}
|
||||
else
|
||||
{
|
||||
@ -199,15 +230,28 @@ try
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// Endpoint mapping — shared first, then mode-specific.
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
app.MapAuthEndpoints();
|
||||
app.MapAdminUserEndpoints();
|
||||
app.MapBrandingEndpoints();
|
||||
app.MapRatesEndpoints();
|
||||
app.MapAdminRatesEndpoints();
|
||||
app.MapSitesEndpoints();
|
||||
app.MapMeasurementsEndpoints();
|
||||
app.MapGrafanaEndpoints();
|
||||
app.MapAdminConfigEndpoints();
|
||||
app.MapAppInfoEndpoints();
|
||||
|
||||
if (applicationOptions.RunMode == RunMode.Client)
|
||||
{
|
||||
app.MapRatesEndpoints();
|
||||
app.MapAdminRatesEndpoints();
|
||||
app.MapSitesEndpoints();
|
||||
app.MapMeasurementsEndpoints();
|
||||
}
|
||||
else
|
||||
{
|
||||
app.MapAdminCustomersEndpoints();
|
||||
}
|
||||
|
||||
app.MapHealthChecks("/health", new Microsoft.AspNetCore.Diagnostics.HealthChecks.HealthCheckOptions
|
||||
{
|
||||
Predicate = _ => false
|
||||
@ -229,3 +273,16 @@ finally
|
||||
{
|
||||
Log.CloseAndFlush();
|
||||
}
|
||||
|
||||
static Action<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;
|
||||
};
|
||||
|
||||
@ -8,12 +8,12 @@ using Tau.Acuvim.Portal.DTOs;
|
||||
namespace Tau.Acuvim.Portal.Services;
|
||||
|
||||
public sealed class BrandingService(
|
||||
AppDbContext db,
|
||||
IWhiteLabelStore store,
|
||||
IOptions<WhiteLabelOptions> defaults)
|
||||
{
|
||||
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);
|
||||
row ??= await SeedAsync(ct);
|
||||
return ToDto(row);
|
||||
@ -21,7 +21,7 @@ public sealed class BrandingService(
|
||||
|
||||
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)
|
||||
?? await SeedAsync(ct);
|
||||
|
||||
@ -33,24 +33,24 @@ public sealed class BrandingService(
|
||||
if (req.LogoUrl is not null) row.LogoUrl = req.LogoUrl;
|
||||
row.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
await store.SaveChangesAsync(ct);
|
||||
return ToDto(row);
|
||||
}
|
||||
|
||||
public async Task<string> SetLogoUrlAsync(string logoUrl, CancellationToken ct = default)
|
||||
{
|
||||
var row = await db.WhiteLabelSettings
|
||||
var row = await store.WhiteLabelSettings
|
||||
.FirstOrDefaultAsync(x => x.Id == WhiteLabelSettings.SingletonId, ct)
|
||||
?? await SeedAsync(ct);
|
||||
row.LogoUrl = logoUrl;
|
||||
row.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync(ct);
|
||||
await store.SaveChangesAsync(ct);
|
||||
return logoUrl;
|
||||
}
|
||||
|
||||
public async Task EnsureSeededAsync(CancellationToken ct = default)
|
||||
{
|
||||
var exists = await db.WhiteLabelSettings
|
||||
var exists = await store.WhiteLabelSettings
|
||||
.AnyAsync(x => x.Id == WhiteLabelSettings.SingletonId, ct);
|
||||
if (!exists) await SeedAsync(ct);
|
||||
}
|
||||
@ -69,8 +69,8 @@ public sealed class BrandingService(
|
||||
FooterText = d.FooterText,
|
||||
UpdatedAt = DateTime.UtcNow
|
||||
};
|
||||
db.WhiteLabelSettings.Add(row);
|
||||
await db.SaveChangesAsync(ct);
|
||||
store.WhiteLabelSettings.Add(row);
|
||||
await store.SaveChangesAsync(ct);
|
||||
return row;
|
||||
}
|
||||
|
||||
|
||||
101
portal/src/Tau.Acuvim.Portal/Services/CustomerService.cs
Normal file
101
portal/src/Tau.Acuvim.Portal/Services/CustomerService.cs
Normal 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);
|
||||
}
|
||||
80
portal/src/Tau.Acuvim.Portal/Services/RunModeGuards.cs
Normal file
80
portal/src/Tau.Acuvim.Portal/Services/RunModeGuards.cs
Normal 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 */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -2,7 +2,16 @@
|
||||
"Application": {
|
||||
"Name": "Power Monitoring Portal",
|
||||
"Environment": "Development",
|
||||
"PublicUrl": "http://localhost:8080"
|
||||
"PublicUrl": "http://localhost:8080",
|
||||
"RunMode": "Client"
|
||||
},
|
||||
"FleetIngest": {
|
||||
"Enabled": false,
|
||||
"Url": "",
|
||||
"Token": "",
|
||||
"IntervalSeconds": 60,
|
||||
"BatchSize": 5000,
|
||||
"BatchMaxBytes": 1048576
|
||||
},
|
||||
"Database": {
|
||||
"Provider": "PostgreSQL",
|
||||
|
||||
42
portal/tests/Tau.Acuvim.Portal.Tests/CustomerTokenTests.cs
Normal file
42
portal/tests/Tau.Acuvim.Portal.Tests/CustomerTokenTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
72
portal/tests/Tau.Acuvim.Portal.Tests/RunModeGuardsTests.cs
Normal file
72
portal/tests/Tau.Acuvim.Portal.Tests/RunModeGuardsTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user