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:
35
web/lib/api.ts
Normal file
35
web/lib/api.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
public readonly status: number,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchJson<T = unknown>(path: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(path, {
|
||||
...init,
|
||||
headers: {
|
||||
...(init?.body ? { 'Content-Type': 'application/json' } : {}),
|
||||
...init?.headers,
|
||||
},
|
||||
});
|
||||
const body = await res.json().catch(() => null);
|
||||
if (!res.ok) {
|
||||
const message = Array.isArray(body?.message)
|
||||
? body.message.join('; ')
|
||||
: body?.message || `Request failed (${res.status})`;
|
||||
throw new ApiError(res.status, message);
|
||||
}
|
||||
return body as T;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: <T = unknown>(path: string) => fetchJson<T>(path),
|
||||
post: <T = unknown>(path: string, data: unknown) =>
|
||||
fetchJson<T>(path, { method: 'POST', body: JSON.stringify(data) }),
|
||||
patch: <T = unknown>(path: string, data: unknown) =>
|
||||
fetchJson<T>(path, { method: 'PATCH', body: JSON.stringify(data) }),
|
||||
delete: <T = unknown>(path: string) => fetchJson<T>(path, { method: 'DELETE' }),
|
||||
};
|
||||
124
web/lib/auth-context.tsx
Normal file
124
web/lib/auth-context.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import { useRouter } from 'next/router';
|
||||
import { createContext, ReactNode, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
||||
import { api, ApiError } from './api';
|
||||
|
||||
export interface AuthUser {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface Membership {
|
||||
role: 'ORG_ADMIN' | 'MEMBER' | 'VIEWER';
|
||||
org: { id: string; name: string; slug: string };
|
||||
}
|
||||
|
||||
interface MeResponse {
|
||||
user: AuthUser;
|
||||
memberships: Membership[];
|
||||
}
|
||||
|
||||
interface AuthContextValue {
|
||||
user: AuthUser | null;
|
||||
memberships: Membership[];
|
||||
activeOrg: Membership | null;
|
||||
loading: boolean;
|
||||
setActiveOrgId: (orgId: string) => void;
|
||||
login: (email: string, password: string) => Promise<void>;
|
||||
register: (data: { email: string; password: string; name: string; orgName: string }) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||
|
||||
const ACTIVE_ORG_KEY = 'ulhub_active_org';
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<AuthUser | null>(null);
|
||||
const [memberships, setMemberships] = useState<Membership[]>([]);
|
||||
const [activeOrgId, setActiveOrgIdState] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const applySession = useCallback((session: MeResponse) => {
|
||||
setUser(session.user);
|
||||
setMemberships(session.memberships);
|
||||
setActiveOrgIdState((current) => {
|
||||
const stored = current ?? localStorage.getItem(ACTIVE_ORG_KEY);
|
||||
const valid = session.memberships.some((m) => m.org.id === stored);
|
||||
return valid ? stored : (session.memberships[0]?.org.id ?? null);
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
api
|
||||
.get<MeResponse>('/api/auth/me')
|
||||
.then(applySession)
|
||||
.catch(() => setUser(null))
|
||||
.finally(() => setLoading(false));
|
||||
}, [applySession]);
|
||||
|
||||
const setActiveOrgId = useCallback((orgId: string) => {
|
||||
localStorage.setItem(ACTIVE_ORG_KEY, orgId);
|
||||
setActiveOrgIdState(orgId);
|
||||
}, []);
|
||||
|
||||
const login = useCallback(
|
||||
async (email: string, password: string) => {
|
||||
applySession(await api.post<MeResponse>('/api/auth/login', { email, password }));
|
||||
},
|
||||
[applySession],
|
||||
);
|
||||
|
||||
const register = useCallback(
|
||||
async (data: { email: string; password: string; name: string; orgName: string }) => {
|
||||
applySession(await api.post<MeResponse>('/api/auth/register', data));
|
||||
},
|
||||
[applySession],
|
||||
);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
await api.post('/api/auth/logout', {}).catch(() => undefined);
|
||||
localStorage.removeItem(ACTIVE_ORG_KEY);
|
||||
setUser(null);
|
||||
setMemberships([]);
|
||||
setActiveOrgIdState(null);
|
||||
}, []);
|
||||
|
||||
const value = useMemo<AuthContextValue>(
|
||||
() => ({
|
||||
user,
|
||||
memberships,
|
||||
activeOrg: memberships.find((m) => m.org.id === activeOrgId) ?? null,
|
||||
loading,
|
||||
setActiveOrgId,
|
||||
login,
|
||||
register,
|
||||
logout,
|
||||
}),
|
||||
[user, memberships, activeOrgId, loading, setActiveOrgId, login, register, logout],
|
||||
);
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
}
|
||||
|
||||
export function useAuth(): AuthContextValue {
|
||||
const ctx = useContext(AuthContext);
|
||||
if (!ctx) {
|
||||
throw new Error('useAuth must be used inside AuthProvider');
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
// Client-side page guard: redirects to /login when unauthenticated.
|
||||
export function useRequireAuth() {
|
||||
const auth = useAuth();
|
||||
const router = useRouter();
|
||||
useEffect(() => {
|
||||
if (!auth.loading && !auth.user) {
|
||||
router.replace('/login');
|
||||
}
|
||||
}, [auth.loading, auth.user, router]);
|
||||
return auth;
|
||||
}
|
||||
|
||||
export { ApiError };
|
||||
60
web/lib/use-job-stream.ts
Normal file
60
web/lib/use-job-stream.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import type { MapPoint } from '../components/map/types';
|
||||
|
||||
// Subscribes to live points for a job over the backend WebSocket.
|
||||
// Reconnects with capped exponential backoff; resubscribes on reconnect.
|
||||
export function useJobStream(jobId: string | null, onPoints: (points: MapPoint[]) => void) {
|
||||
const handlerRef = useRef(onPoints);
|
||||
handlerRef.current = onPoints;
|
||||
|
||||
useEffect(() => {
|
||||
if (!jobId) {
|
||||
return;
|
||||
}
|
||||
|
||||
let socket: WebSocket | null = null;
|
||||
let closed = false;
|
||||
let attempt = 0;
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const connect = () => {
|
||||
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
socket = new WebSocket(`${proto}://${window.location.host}/api/ws`);
|
||||
|
||||
socket.onopen = () => {
|
||||
attempt = 0;
|
||||
socket?.send(JSON.stringify({ type: 'subscribe', channel: `job:${jobId}` }));
|
||||
};
|
||||
|
||||
socket.onmessage = (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data);
|
||||
if (msg.type === 'points' && msg.jobId === jobId) {
|
||||
handlerRef.current(msg.points);
|
||||
}
|
||||
} catch {
|
||||
// ignore malformed frames
|
||||
}
|
||||
};
|
||||
|
||||
socket.onclose = () => {
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
attempt += 1;
|
||||
const delay = Math.min(1000 * 2 ** attempt, 15000);
|
||||
timer = setTimeout(connect, delay);
|
||||
};
|
||||
};
|
||||
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
closed = true;
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
socket?.close();
|
||||
};
|
||||
}, [jobId]);
|
||||
}
|
||||
Reference in New Issue
Block a user