Build core domain: orgs, users, jobs, locate points, auth, live map

Replaces the device-events demo with the actual product:

- Prisma + PostGIS data layer (postgis/postgis:17-3.5). Lat/lng decimals
  are the source of truth; a generated geometry(Point,4326) column with
  a GIST index backs bbox queries. Migrations apply on container boot.
- JWT auth (bcryptjs + httpOnly cookie) with public registration that
  creates an org; per-org roles (ORG_ADMIN/MEMBER/VIEWER) enforced by
  guards on all /orgs/:orgId routes.
- Scoped API keys (X-API-Key, sha256-hashed, shown once) for
  programmatic access, manageable by org admins.
- REST API: jobs/tickets CRUD with filters, points query (time range,
  recordedAt cursor, bbox), members, devices, api-keys.
- MQTT ingest: devices publish to devices/{username}/points and /jobs;
  unknown tickets auto-create stub jobs (source=DEVICE); every message
  is raw-logged to device_events; acks on devices/{username}/jobs/ack.
  Broker gets a dedicated backend user; testuser is now a plain device.
- Realtime: plain-WS gateway at /api/ws (socket.io removed) with
  cookie auth and per-job channels feeding the map live.
- Next.js frontend: login/register, jobs list with filters, job detail
  with live Google map (APWA utility colors, polylines per run) behind
  a provider-neutral JobMap abstraction for a future Esri swap, and
  settings pages for members/devices/api-keys.
- Seed: Umagul org, admin user, testuser device, demo job with RTK
  points. Sample publisher updated to the new topic contract.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
ulhub
2026-07-14 12:43:25 +00:00
parent cbe0cc6da2
commit eae4075265
79 changed files with 10215 additions and 494 deletions

View File

@@ -1,114 +1,118 @@
import Head from 'next/head';
import Link from 'next/link';
import { useEffect, useState } from 'react';
import Layout from '../components/Layout';
import { api } from '../lib/api';
import { useRequireAuth } from '../lib/auth-context';
const WS_PATH = '/api/device-events/ws';
interface ApiStatus {
message: string;
docs: string;
interface JobRow {
id: string;
ticketNumber: string;
title: string;
address: string | null;
status: string;
source: string;
createdAt: string;
assignedTo: { id: string; name: string } | null;
_count: { points: number };
}
interface DeviceRecord {
id: number;
topic: string;
payload: string;
receivedAt: string;
}
const STATUSES = ['', 'OPEN', 'IN_PROGRESS', 'COMPLETED', 'CANCELLED'];
export default function Home() {
const [status, setStatus] = useState<ApiStatus | null>(null);
const STATUS_COLORS: Record<string, string> = {
OPEN: '#1976d2',
IN_PROGRESS: '#f57c00',
COMPLETED: '#388e3c',
CANCELLED: '#9e9e9e',
};
export default function JobsPage() {
const { user, activeOrg, loading } = useRequireAuth();
const [jobs, setJobs] = useState<JobRow[]>([]);
const [total, setTotal] = useState(0);
const [status, setStatus] = useState('');
const [q, setQ] = useState('');
const [error, setError] = useState<string | null>(null);
const [messages, setMessages] = useState<DeviceRecord[]>([]);
const [connected, setConnected] = useState(false);
useEffect(() => {
fetch('/api/status')
.then((res) => res.json())
.then(setStatus)
.catch((err) => setError(err.message));
}, []);
useEffect(() => {
fetch('/api/device-events')
.then((res) => res.json())
.then((payload) => {
const records = Array.isArray(payload) ? payload : payload?.records ?? [];
setMessages(Array.isArray(records) ? records : []);
setConnected(true);
if (!activeOrg) {
return;
}
const params = new URLSearchParams();
if (status) params.set('status', status);
if (q) params.set('q', q);
api
.get<{ jobs: JobRow[]; total: number }>(`/api/orgs/${activeOrg.org.id}/jobs?${params}`)
.then((res) => {
setJobs(res.jobs);
setTotal(res.total);
setError(null);
})
.catch((err) => {
setError(err.message || 'Unable to load device events');
});
.catch((err) => setError(err.message));
}, [activeOrg, status, q]);
const wsProtocol = window.location.protocol === 'https:' ? 'wss' : 'ws';
const socket = new WebSocket(`${wsProtocol}://${window.location.host}${WS_PATH}`);
socket.onopen = () => {
setConnected(true);
setError(null);
};
socket.onmessage = (event) => {
try {
const payload = JSON.parse(event.data);
if (payload.type === 'connected') {
setConnected(true);
return;
}
setMessages((prev) => [payload, ...prev].slice(0, 20));
} catch (err) {
console.error('Failed to parse device-event payload', err);
}
};
socket.onerror = () => {
setConnected(false);
setError('Realtime stream disconnected');
};
return () => {
socket.close();
};
}, []);
if (loading || !user) {
return null;
}
return (
<>
<Head>
<title>UlHub</title>
<meta name="description" content="UlHub monolithic app" />
</Head>
<main style={{ padding: '3rem', fontFamily: 'system-ui, sans-serif', maxWidth: '900px' }}>
<h1>UlHub</h1>
<p>React + Next.js frontend with NestJS backend and live MQTT WebSocket data.</p>
<section style={{ marginTop: '1.5rem' }}>
<h2>API Status</h2>
{error && <p style={{ color: 'red' }}>{error}</p>}
{status ? (
<div>
<p>{status.message}</p>
<p>{status.docs}</p>
</div>
) : (
<p>Loading status...</p>
)}
</section>
<section style={{ marginTop: '1.5rem' }}>
<h2>Device Events</h2>
<p>Status: {connected ? 'Connected' : 'Connecting...'}</p>
<ul>
{(Array.isArray(messages) ? messages : []).map((msg, index) => (
<li key={`${msg.topic}-${index}`} style={{ marginBottom: '0.75rem' }}>
<strong>{msg.topic}</strong> <span style={{ color: '#666' }}>{msg.receivedAt}</span>
<br />
{msg.payload}
</li>
<Layout title="Jobs">
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', marginBottom: '1rem' }}>
<h1 style={{ margin: 0 }}>Jobs</h1>
<span style={{ color: '#888' }}>{total} total</span>
<div style={{ marginLeft: 'auto', display: 'flex', gap: '0.5rem' }}>
<input placeholder="Search ticket, title, address…" value={q} onChange={(e) => setQ(e.target.value)} />
<select value={status} onChange={(e) => setStatus(e.target.value)}>
{STATUSES.map((s) => (
<option key={s} value={s}>
{s || 'All statuses'}
</option>
))}
</ul>
</section>
</main>
</>
</select>
<Link href="/jobs/new">
<button>+ New job</button>
</Link>
</div>
</div>
{error && <p style={{ color: '#c62828' }}>{error}</p>}
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ textAlign: 'left', borderBottom: '2px solid #ddd' }}>
<th style={{ padding: '0.5rem' }}>Ticket</th>
<th>Title</th>
<th>Address</th>
<th>Status</th>
<th>Source</th>
<th>Assignee</th>
<th style={{ textAlign: 'right' }}>Points</th>
</tr>
</thead>
<tbody>
{jobs.map((job) => (
<tr key={job.id} style={{ borderBottom: '1px solid #eee' }}>
<td style={{ padding: '0.5rem' }}>
<Link href={`/jobs/${job.id}`}>{job.ticketNumber}</Link>
</td>
<td>{job.title}</td>
<td>{job.address ?? '—'}</td>
<td>
<span style={{ color: STATUS_COLORS[job.status] ?? '#333', fontWeight: 600 }}>{job.status}</span>
</td>
<td>{job.source}</td>
<td>{job.assignedTo?.name ?? '—'}</td>
<td style={{ textAlign: 'right' }}>{job._count.points}</td>
</tr>
))}
{jobs.length === 0 && !error && (
<tr>
<td colSpan={7} style={{ padding: '2rem', textAlign: 'center', color: '#888' }}>
No jobs yet. Create one, or let a device post points to auto-create its ticket.
</td>
</tr>
)}
</tbody>
</table>
</Layout>
);
}