first project commit

This commit is contained in:
Pedro Gomes 2026-05-16 12:02:15 +01:00
parent 33789b13d1
commit c013b52f59
79 changed files with 5834 additions and 0 deletions

View File

@ -0,0 +1,15 @@
{
"permissions": {
"allow": [
"PowerShell(corepack enable pnpm)",
"PowerShell(corepack prepare pnpm@latest --activate)",
"PowerShell(pnpm --version)",
"PowerShell(docker --version)",
"PowerShell(docker compose version)",
"PowerShell(docker exec fieldops-postgres psql -U fieldops -d fieldops -c \"\\\\dt\" 2>&1)",
"PowerShell(docker exec fieldops-postgres psql -U fieldops -d fieldops -c \"SELECT id, name FROM \\\\`\"Tenant\\\\`\";\" 2>&1)",
"PowerShell(docker exec fieldops-postgres psql -U fieldops -d fieldops -c \"SELECT email, role FROM \\\\`\"User\\\\`\";\" 2>&1)",
"PowerShell(docker exec fieldops-postgres psql -U fieldops -d fieldops -c \"SELECT code, name, area FROM \\\\`\"Workstation\\\\`\";\" 2>&1)"
]
}
}

33
.env.example Normal file
View File

@ -0,0 +1,33 @@
# ---------------------------------------------------------------------------
# FieldOps — environment variables
# ---------------------------------------------------------------------------
# Copy this file to .env (cp .env.example .env) and adjust as needed for your
# local environment. Never commit .env.
# Postgres connection string. Matches docker-compose.yml defaults.
DATABASE_URL="postgresql://fieldops:fieldops@localhost:5432/fieldops?schema=public"
# Auth.js v5 — secret used to sign session tokens.
# In production, set this to a strong random value: `openssl rand -base64 32`.
AUTH_SECRET="dev-secret-do-not-use-in-production-please-change-me"
# Dev-only auto sign-in.
# When set to "true", the app will silently sign in as the seed admin user
# (admin@demo.local of the "Demo Factory" tenant) on every request that has
# no session. This skips the login UI in local development and CI/E2E.
#
# !!! NEVER set this to "true" in production. !!!
# The default of "false" here is intentional — a developer setting up locally
# must consciously opt in by editing their .env. See README "Auth" section.
AUTH_DEV_AUTOLOGIN="false"
# Base URL of the operator-pwa app — used by Auth.js for callback URLs.
NEXT_PUBLIC_APP_URL="http://localhost:3000"
AUTH_URL="http://localhost:3000"
# Pino log level — one of: fatal, error, warn, info, debug, trace.
LOG_LEVEL="info"
# Node environment — Next.js sets this automatically; included here for
# packages that need it at module load time.
NODE_ENV="development"

5
.npmrc Normal file
View File

@ -0,0 +1,5 @@
engine-strict=true
auto-install-peers=true
strict-peer-dependencies=false
prefer-workspace-packages=true
link-workspace-packages=true

1
.nvmrc Normal file
View File

@ -0,0 +1 @@
22

11
.prettierignore Normal file
View File

@ -0,0 +1,11 @@
node_modules
.next
.turbo
dist
build
coverage
playwright-report
test-results
pnpm-lock.yaml
*.tsbuildinfo
packages/db/prisma/migrations

12
.prettierrc.json Normal file
View File

@ -0,0 +1,12 @@
{
"semi": true,
"singleQuote": true,
"trailingComma": "all",
"printWidth": 100,
"tabWidth": 2,
"useTabs": false,
"arrowParens": "always",
"endOfLine": "lf",
"plugins": ["prettier-plugin-tailwindcss"],
"tailwindFunctions": ["cn", "clsx", "cva"]
}

View File

@ -0,0 +1,25 @@
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'FieldOps Admin',
description: 'Backoffice — coming soon.',
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body
style={{
fontFamily: 'system-ui, sans-serif',
margin: 0,
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
{children}
</body>
</html>
);
}

View File

@ -0,0 +1,8 @@
export default function Page() {
return (
<main style={{ textAlign: 'center' }}>
<h1 style={{ fontSize: '2rem', marginBottom: '0.5rem' }}>FieldOps Admin</h1>
<p style={{ color: '#64748b' }}>Coming soon.</p>
</main>
);
}

View File

@ -0,0 +1,8 @@
import type { NextConfig } from 'next';
const config: NextConfig = {
reactStrictMode: true,
poweredByHeader: false,
};
export default config;

View File

@ -0,0 +1,28 @@
{
"name": "@repo/admin-web",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "dotenv -e ../../.env -- next dev --port 3001",
"build": "dotenv -e ../../.env -- next build",
"start": "dotenv -e ../../.env -- next start --port 3001",
"lint": "next lint",
"typecheck": "tsc --noEmit",
"clean": "rimraf .next .turbo node_modules"
},
"dependencies": {
"next": "^15.1.3",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@repo/config": "workspace:*",
"@types/node": "^22.10.2",
"@types/react": "^19.0.2",
"@types/react-dom": "^19.0.2",
"dotenv-cli": "^8.0.0",
"rimraf": "^6.0.1",
"typescript": "^5.7.2"
}
}

View File

@ -0,0 +1,9 @@
{
"extends": "@repo/config/tsconfig/nextjs.json",
"compilerOptions": {
"baseUrl": ".",
"paths": { "@/*": ["./*"] }
},
"include": ["**/*.ts", "**/*.tsx", ".next/types/**/*.ts", "next-env.d.ts"],
"exclude": ["node_modules", ".next"]
}

View File

@ -0,0 +1,4 @@
import { handlers } from '@/lib/auth';
export const { GET, POST } = handlers;
export const runtime = 'nodejs';

View File

@ -0,0 +1,25 @@
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
import { appRouter, createTRPCContext } from '@repo/api';
import { resolveUser } from '@/lib/auth';
export const runtime = 'nodejs';
const handler = async (req: Request) => {
return fetchRequestHandler({
endpoint: '/api/trpc',
req,
router: appRouter,
createContext: async () => {
const user = await resolveUser();
return createTRPCContext({ user, headers: req.headers });
},
onError({ error, path }) {
if (process.env.NODE_ENV === 'development') {
// eslint-disable-next-line no-console
console.error(`[trpc] ${path ?? '<no-path>'}:`, error.message);
}
},
});
};
export { handler as GET, handler as POST };

View File

@ -0,0 +1,58 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
--radius: 0.5rem;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9%;
}
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}

View File

@ -0,0 +1,31 @@
import type { Metadata, Viewport } from 'next';
import { Providers } from './providers';
import './globals.css';
export const metadata: Metadata = {
title: 'FieldOps — Operator',
description: 'Industrial operator console.',
manifest: '/manifest.webmanifest',
applicationName: 'FieldOps Operator',
appleWebApp: {
capable: true,
title: 'FieldOps Operator',
statusBarStyle: 'default',
},
};
export const viewport: Viewport = {
themeColor: '#0f172a',
width: 'device-width',
initialScale: 1,
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body className="min-h-screen bg-background font-sans antialiased">
<Providers>{children}</Providers>
</body>
</html>
);
}

View File

@ -0,0 +1,88 @@
import { TRPCError } from '@trpc/server';
import { CheckCircle2, AlertCircle } from 'lucide-react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@repo/ui';
import { Alert, AlertDescription, AlertTitle } from '@repo/ui';
import { api } from '@/lib/trpc/server';
import { PingClient } from './ping-client';
/**
* Smoke-test home page. Uses the RSC tRPC caller (server-side) to invoke the
* ping procedure end-to-end:
*
* RSC tRPC caller protectedProcedure Prisma Postgres Tenant row
*
* If the call throws (e.g. UNAUTHORIZED because no session), the error is
* caught and rendered as a legible failure card.
*/
export default async function HomePage() {
let result:
| { ok: true; payload: Awaited<ReturnType<typeof api.ping.ping>> }
| { ok: false; message: string; code: string } = {
ok: false,
message: 'init',
code: 'INIT',
};
try {
const payload = await api.ping.ping();
result = { ok: true, payload };
} catch (err) {
if (err instanceof TRPCError) {
result = { ok: false, message: err.message, code: err.code };
} else if (err instanceof Error) {
result = { ok: false, message: err.message, code: 'UNKNOWN' };
} else {
result = { ok: false, message: String(err), code: 'UNKNOWN' };
}
}
return (
<main className="mx-auto flex min-h-screen max-w-2xl flex-col items-stretch justify-center gap-6 p-6">
<header className="text-center">
<h1 className="text-3xl font-bold tracking-tight">FieldOps Operator</h1>
<p className="text-sm text-muted-foreground">Scaffold smoke test</p>
</header>
{result.ok ? (
<Card data-testid="ping-success">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<CheckCircle2 className="h-5 w-5 text-green-600" />
Connected
</CardTitle>
<CardDescription>
End-to-end path verified: RSC tRPC Prisma Postgres.
</CardDescription>
</CardHeader>
<CardContent className="space-y-2 text-sm">
<div>
<span className="font-medium">Tenant: </span>
<span data-testid="tenant-name">{result.payload.tenant.name}</span>
</div>
<div className="text-muted-foreground">
<span className="font-medium">id:</span> {result.payload.tenant.id}
</div>
<div className="text-muted-foreground">
<span className="font-medium">at:</span> {result.payload.timestamp}
</div>
</CardContent>
</Card>
) : (
<Alert variant="destructive" data-testid="ping-failure">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Ping failed ({result.code})</AlertTitle>
<AlertDescription className="space-y-2">
<p>{result.message}</p>
<p className="text-xs">
If this says <code>UNAUTHORIZED</code>, set{' '}
<code>AUTH_DEV_AUTOLOGIN=true</code> in <code>.env</code> for local dev,
or sign in via Auth.js.
</p>
</AlertDescription>
</Alert>
)}
<PingClient />
</main>
);
}

View File

@ -0,0 +1,43 @@
'use client';
import { CheckCircle2, AlertCircle, Loader2 } from 'lucide-react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@repo/ui';
import { trpc } from '@/lib/trpc/client';
/**
* Client-side ping. Demonstrates the second tRPC path: client hooks +
* TanStack Query. The RSC caller above the fold and this hook hit the same
* procedure both must succeed for the hybrid wiring to be considered green.
*/
export function PingClient() {
const query = trpc.ping.ping.useQuery();
return (
<Card data-testid="ping-client">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
{query.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : query.isError ? (
<AlertCircle className="h-4 w-4 text-destructive" />
) : (
<CheckCircle2 className="h-4 w-4 text-green-600" />
)}
Client-side ping (useQuery)
</CardTitle>
<CardDescription>Round-trips through /api/trpc.</CardDescription>
</CardHeader>
<CardContent className="text-sm">
{query.isPending && <span>Loading</span>}
{query.isError && (
<span className="text-destructive" data-testid="ping-client-error">
{query.error.message}
</span>
)}
{query.data && (
<span data-testid="ping-client-tenant">tenant: {query.data.tenant.name}</span>
)}
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,39 @@
'use client';
import { useState, type ReactNode } from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { httpBatchLink } from '@trpc/client';
import superjson from 'superjson';
import { trpc } from '@/lib/trpc/client';
function makeTrpcClient() {
return trpc.createClient({
links: [
httpBatchLink({
url: '/api/trpc',
transformer: superjson,
}),
],
});
}
export function Providers({ children }: { children: ReactNode }) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 30 * 1000,
refetchOnWindowFocus: false,
},
},
}),
);
const [trpcClient] = useState(makeTrpcClient);
return (
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
</trpc.Provider>
);
}

36
apps/operator-pwa/env.ts Normal file
View File

@ -0,0 +1,36 @@
import { createEnv } from '@t3-oss/env-nextjs';
import { z } from 'zod';
/**
* Zod-validated environment. Imported eagerly from next.config.ts so that
* missing/invalid variables fail the build instead of silently leaking
* `undefined` at runtime.
*/
export const env = createEnv({
server: {
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
DATABASE_URL: z.string().url(),
AUTH_SECRET: z.string().min(1, 'AUTH_SECRET is required'),
AUTH_URL: z.string().url().optional(),
AUTH_DEV_AUTOLOGIN: z
.string()
.optional()
.transform((v) => v === 'true'),
LOG_LEVEL: z
.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace'])
.default('info'),
},
client: {
NEXT_PUBLIC_APP_URL: z.string().url(),
},
runtimeEnv: {
NODE_ENV: process.env.NODE_ENV,
DATABASE_URL: process.env.DATABASE_URL,
AUTH_SECRET: process.env.AUTH_SECRET,
AUTH_URL: process.env.AUTH_URL,
AUTH_DEV_AUTOLOGIN: process.env.AUTH_DEV_AUTOLOGIN,
LOG_LEVEL: process.env.LOG_LEVEL,
NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
},
emptyStringAsUndefined: true,
});

View File

@ -0,0 +1,40 @@
import type { NextAuthConfig } from 'next-auth';
/**
* Edge-safe portion of the Auth.js config. The middleware imports THIS, never
* the full `auth.ts` Credentials providers and the Prisma client are not
* edge-compatible, so they live exclusively in auth.ts which runs in the
* Node.js runtime (route handlers).
*/
export const authConfig = {
trustHost: true,
session: { strategy: 'jwt' },
pages: {
// No login UI in this scaffold phase. See auth.ts for the placeholder.
},
callbacks: {
async jwt({ token, user }) {
if (user) {
// user is the value returned from `authorize()` in the Credentials provider.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const u = user as any;
token.id = u.id;
token.role = u.role;
token.tenantId = u.tenantId;
}
return token;
},
async session({ session, token }) {
if (token && session.user) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(session.user as any).id = token.id;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(session.user as any).role = token.role;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(session.user as any).tenantId = token.tenantId;
}
return session;
},
},
providers: [],
} satisfies NextAuthConfig;

View File

@ -0,0 +1,93 @@
import NextAuth from 'next-auth';
import Credentials from 'next-auth/providers/credentials';
import { prisma } from '@repo/db';
import type { SessionUser } from '@repo/api';
import { env } from '../env';
import { authConfig } from './auth.config';
/**
* ============================================================================
* Auth.js v5 PLACEHOLDER configuration for the scaffold phase.
* ============================================================================
*
* The Credentials provider below accepts ANY email that exists in the User
* table (seeded by `pnpm db:seed`). NO PASSWORD CHECK is performed. This is
* deliberately minimal just enough to populate the tRPC context with a real
* Auth.js session and MUST be replaced with real authentication before any
* non-dev deployment.
*
* Auto sign-in
* ------------
* See `resolveUser()` below. When AUTH_DEV_AUTOLOGIN=true, server-side code
* that has no session falls back to the seeded admin user. This is a back
* door and is gated by an explicit env flag whose default in .env.example is
* FALSE.
*
* !!! NEVER set AUTH_DEV_AUTOLOGIN=true in production. !!!
*
* In production with AUTH_DEV_AUTOLOGIN unset/false, requests without a
* signed Auth.js session resolve to user=null, and protectedProcedure throws
* 401.
* ============================================================================
*/
export const { handlers, auth, signIn, signOut } = NextAuth({
...authConfig,
secret: env.AUTH_SECRET,
providers: [
Credentials({
name: 'Email (placeholder)',
credentials: {
email: { label: 'Email', type: 'email' },
},
async authorize(credentials) {
const email = credentials?.email;
if (typeof email !== 'string' || !email) return null;
const user = await prisma.user.findFirst({ where: { email } });
if (!user) return null;
// NO password verification — placeholder only.
return {
id: user.id,
email: user.email,
name: user.email,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
role: user.role as any,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
tenantId: user.tenantId as any,
};
},
}),
],
});
/**
* Resolve the current user for server-side code (RSC, route handlers, tRPC).
* Single chokepoint that combines the real Auth.js session with the dev-only
* auto-login fallback. Application code MUST use this and not call `auth()`
* directly when it expects to honour AUTH_DEV_AUTOLOGIN.
*/
export async function resolveUser(): Promise<SessionUser | null> {
const session = await auth();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const u = session?.user as any;
if (u?.id && u?.tenantId) {
return { id: u.id, email: u.email, role: u.role, tenantId: u.tenantId };
}
if (env.AUTH_DEV_AUTOLOGIN) {
// Dev back door. Production guards: env flag default is false; this branch
// is also a no-op if the seed user doesn't exist.
const admin = await prisma.user.findFirst({ where: { email: 'admin@demo.local' } });
if (admin) {
return {
id: admin.id,
email: admin.email,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
role: admin.role as any,
tenantId: admin.tenantId,
};
}
}
return null;
}

View File

@ -0,0 +1,12 @@
'use client';
import { createTRPCReact } from '@trpc/react-query';
import type { AppRouter } from '@repo/api';
/**
* Typed tRPC React Query hooks for client components.
*
* import { trpc } from '@/lib/trpc/client';
* const { data } = trpc.ping.ping.useQuery();
*/
export const trpc = createTRPCReact<AppRouter>();

View File

@ -0,0 +1,30 @@
import 'server-only';
import { cache } from 'react';
import { headers } from 'next/headers';
import {
appRouter,
createCallerFactory,
createTRPCContext,
type AppRouter,
} from '@repo/api';
import type { inferRouterInputs, inferRouterOutputs } from '@trpc/server';
import { resolveUser } from '../auth';
/**
* RSC-side tRPC caller. Bypasses HTTP runs the router directly inside the
* server component. Use this for reads in Server Components / Server Actions.
*
* For client-side reads/mutations, see `./client.ts` (TanStack Query hooks).
*/
const createContext = cache(async () => {
const user = await resolveUser();
const h = await headers();
return createTRPCContext({ user, headers: h });
});
const createCaller = createCallerFactory(appRouter);
export const api = createCaller(createContext);
export type RouterInputs = inferRouterInputs<AppRouter>;
export type RouterOutputs = inferRouterOutputs<AppRouter>;

View File

@ -0,0 +1,17 @@
import NextAuth from 'next-auth';
import { authConfig } from './lib/auth.config';
// Edge-runtime middleware. Uses the edge-safe authConfig (no Credentials
// provider, no Prisma) — it only validates and refreshes the JWT cookie. The
// full auth config with the Credentials provider lives in lib/auth.ts and
// runs in the Node.js runtime via the route handlers.
export default NextAuth(authConfig).auth;
export const config = {
matcher: [
// Run on every path except static assets, image optimization, and the
// PWA manifest. The Auth.js / tRPC API routes are excluded explicitly
// because they handle session resolution themselves.
'/((?!api/auth|api/trpc|_next/static|_next/image|favicon.ico|manifest.webmanifest|icon-.*\\.svg).*)',
],
};

View File

@ -0,0 +1,14 @@
import type { NextConfig } from 'next';
import './env';
const config: NextConfig = {
transpilePackages: ['@repo/db', '@repo/api', '@repo/ui', '@repo/domain'],
reactStrictMode: true,
poweredByHeader: false,
// Pino uses worker_threads via pino-pretty. Next's server bundler doesn't
// emit the worker chunk correctly — mark these as external so they're
// required straight from node_modules at runtime.
serverExternalPackages: ['pino', 'pino-pretty'],
};
export default config;

View File

@ -0,0 +1,46 @@
{
"name": "@repo/operator-pwa",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "dotenv -e ../../.env -- next dev --port 3000",
"build": "dotenv -e ../../.env -- next build",
"start": "dotenv -e ../../.env -- next start --port 3000",
"lint": "next lint",
"typecheck": "tsc --noEmit",
"clean": "rimraf .next .turbo node_modules"
},
"dependencies": {
"@repo/api": "workspace:*",
"@repo/db": "workspace:*",
"@repo/domain": "workspace:*",
"@repo/ui": "workspace:*",
"@t3-oss/env-nextjs": "^0.11.1",
"@tanstack/react-query": "^5.62.10",
"@trpc/client": "^11.0.0",
"@trpc/react-query": "^11.0.0",
"@trpc/server": "^11.0.0",
"lucide-react": "^0.469.0",
"next": "^15.1.3",
"next-auth": "5.0.0-beta.25",
"pino": "^9.5.0",
"pino-pretty": "^11.3.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"superjson": "^2.2.2",
"zod": "^3.24.1"
},
"devDependencies": {
"@repo/config": "workspace:*",
"@types/node": "^22.10.2",
"@types/react": "^19.0.2",
"@types/react-dom": "^19.0.2",
"autoprefixer": "^10.4.20",
"dotenv-cli": "^8.0.0",
"postcss": "^8.4.49",
"rimraf": "^6.0.1",
"tailwindcss": "^3.4.17",
"typescript": "^5.7.2"
}
}

View File

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View File

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 192 192" width="192" height="192">
<rect width="192" height="192" rx="32" fill="#0f172a"/>
<text x="96" y="116" font-family="system-ui, sans-serif" font-size="64" font-weight="700" fill="#f8fafc" text-anchor="middle">FO</text>
</svg>

After

Width:  |  Height:  |  Size: 291 B

View File

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
<rect width="512" height="512" rx="88" fill="#0f172a"/>
<text x="256" y="310" font-family="system-ui, sans-serif" font-size="180" font-weight="700" fill="#f8fafc" text-anchor="middle">FO</text>
</svg>

After

Width:  |  Height:  |  Size: 293 B

View File

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
<rect width="512" height="512" fill="#0f172a"/>
<text x="256" y="310" font-family="system-ui, sans-serif" font-size="160" font-weight="700" fill="#f8fafc" text-anchor="middle">FO</text>
</svg>

After

Width:  |  Height:  |  Size: 285 B

View File

@ -0,0 +1,30 @@
{
"name": "FieldOps Operator",
"short_name": "FieldOps",
"description": "Industrial operator console.",
"start_url": "/",
"display": "standalone",
"background_color": "#0f172a",
"theme_color": "#0f172a",
"orientation": "any",
"icons": [
{
"src": "/icon-192.svg",
"sizes": "192x192",
"type": "image/svg+xml",
"purpose": "any"
},
{
"src": "/icon-512.svg",
"sizes": "512x512",
"type": "image/svg+xml",
"purpose": "any"
},
{
"src": "/icon-maskable.svg",
"sizes": "512x512",
"type": "image/svg+xml",
"purpose": "maskable"
}
]
}

View File

@ -0,0 +1,14 @@
import type { Config } from 'tailwindcss';
import preset from '@repo/config/tailwind/preset';
const config: Config = {
presets: [preset],
content: [
'./app/**/*.{ts,tsx}',
'./components/**/*.{ts,tsx}',
'./lib/**/*.{ts,tsx}',
'../../packages/ui/src/**/*.{ts,tsx}',
],
};
export default config;

View File

@ -0,0 +1,11 @@
{
"extends": "@repo/config/tsconfig/nextjs.json",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./*"]
}
},
"include": ["**/*.ts", "**/*.tsx", ".next/types/**/*.ts", "next-env.d.ts"],
"exclude": ["node_modules", ".next"]
}

23
docker-compose.yml Normal file
View File

@ -0,0 +1,23 @@
name: fieldops
services:
postgres:
image: postgres:16-alpine
container_name: fieldops-postgres
restart: unless-stopped
environment:
POSTGRES_USER: fieldops
POSTGRES_PASSWORD: fieldops
POSTGRES_DB: fieldops
ports:
- "5432:5432"
volumes:
- postgres-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U fieldops -d fieldops"]
interval: 5s
timeout: 5s
retries: 10
volumes:
postgres-data:

21
e2e/package.json Normal file
View File

@ -0,0 +1,21 @@
{
"name": "@repo/e2e",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"test": "playwright test",
"test:headed": "playwright test --headed",
"test:ui": "playwright test --ui",
"report": "playwright show-report",
"install-browsers": "playwright install chromium",
"clean": "rimraf node_modules playwright-report test-results .playwright"
},
"devDependencies": {
"@playwright/test": "^1.49.1",
"@repo/config": "workspace:*",
"@types/node": "^22.10.2",
"rimraf": "^6.0.1",
"typescript": "^5.7.2"
}
}

40
e2e/playwright.config.ts Normal file
View File

@ -0,0 +1,40 @@
import { defineConfig, devices } from '@playwright/test';
const PORT = 3000;
const BASE_URL = `http://localhost:${PORT}`;
export default defineConfig({
testDir: './tests',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: [['list'], ['html', { open: 'never' }]],
use: {
baseURL: BASE_URL,
trace: 'retain-on-failure',
screenshot: 'only-on-failure',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
webServer: {
// Run from the repo root so workspace resolution works.
command: 'pnpm --filter @repo/operator-pwa dev',
cwd: '..',
url: BASE_URL,
reuseExistingServer: !process.env.CI,
timeout: 120_000,
stdout: 'pipe',
stderr: 'pipe',
// Force the dev autologin on for E2E regardless of the developer's local
// .env. This env applies only to the child dev server, not the test
// process itself.
env: {
AUTH_DEV_AUTOLOGIN: 'true',
},
},
});

18
e2e/tests/ping.spec.ts Normal file
View File

@ -0,0 +1,18 @@
import { test, expect } from '@playwright/test';
test.describe('ping smoke', () => {
test('home page shows the seeded Demo Factory tenant', async ({ page }) => {
await page.goto('/');
// RSC card with the server-side ping result.
const success = page.getByTestId('ping-success');
await expect(success).toBeVisible({ timeout: 15_000 });
const tenant = page.getByTestId('tenant-name');
await expect(tenant).toHaveText('Demo Factory');
// Client-side hook hitting the same procedure via /api/trpc.
const clientTenant = page.getByTestId('ping-client-tenant');
await expect(clientTenant).toContainText('Demo Factory');
});
});

10
e2e/tsconfig.json Normal file
View File

@ -0,0 +1,10 @@
{
"extends": "@repo/config/tsconfig/base.json",
"compilerOptions": {
"noEmit": true,
"lib": ["ES2022", "DOM"],
"module": "ESNext",
"moduleResolution": "Bundler"
},
"include": ["**/*.ts"]
}

36
package.json Normal file
View File

@ -0,0 +1,36 @@
{
"name": "fieldops",
"version": "0.0.0",
"private": true,
"description": "FieldOps — modular industrial SaaS monorepo",
"packageManager": "pnpm@11.1.2",
"engines": {
"node": ">=22.0.0",
"pnpm": ">=11.0.0"
},
"scripts": {
"dev": "turbo run dev",
"build": "turbo run build",
"lint": "turbo run lint",
"typecheck": "turbo run typecheck",
"test": "turbo run test",
"test:e2e": "pnpm --filter @repo/e2e test",
"db:generate": "pnpm --filter @repo/db exec prisma generate",
"db:migrate": "pnpm --filter @repo/db exec prisma migrate dev",
"db:migrate:deploy": "pnpm --filter @repo/db exec prisma migrate deploy",
"db:seed": "pnpm --filter @repo/db seed",
"db:studio": "pnpm --filter @repo/db exec prisma studio",
"db:reset": "pnpm --filter @repo/db exec prisma migrate reset --force",
"format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md,yml,yaml}\"",
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,json,md,yml,yaml}\"",
"clean": "turbo run clean && rimraf node_modules .turbo"
},
"devDependencies": {
"@repo/config": "workspace:*",
"prettier": "^3.4.2",
"prettier-plugin-tailwindcss": "^0.6.9",
"rimraf": "^6.0.1",
"turbo": "^2.3.3",
"typescript": "^5.7.2"
}
}

29
packages/api/package.json Normal file
View File

@ -0,0 +1,29 @@
{
"name": "@repo/api",
"version": "0.0.0",
"private": true,
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts",
"./context": "./src/context.ts",
"./trpc": "./src/trpc.ts"
},
"scripts": {
"typecheck": "tsc --noEmit",
"clean": "rimraf .turbo node_modules"
},
"dependencies": {
"@repo/db": "workspace:*",
"@trpc/server": "^11.0.0",
"pino": "^9.5.0",
"superjson": "^2.2.2",
"zod": "^3.24.1"
},
"devDependencies": {
"@repo/config": "workspace:*",
"rimraf": "^6.0.1",
"typescript": "^5.7.2"
}
}

View File

@ -0,0 +1,45 @@
import { prisma, tenantScoped, type TenantScopedClient, type DbClient } from '@repo/db';
import { logger } from './logger';
/**
* Authenticated user shape passed in by the app layer.
*
* @repo/api does NOT depend on next-auth directly that would entangle the API
* layer with a specific auth implementation. Instead, the Next route handler
* resolves Auth.js's Session and adapts it into this minimal shape.
*/
export type SessionUser = {
id: string;
email: string;
role: 'ADMIN' | 'SUPERVISOR' | 'OPERATOR';
tenantId: string;
};
export type CreateContextOptions = {
user: SessionUser | null;
headers: Headers;
};
export type Context = {
/** Unscoped Prisma client. Use only for cross-tenant operations (e.g. login lookup). */
prisma: DbClient;
/** Tenant-scoped Prisma client. NULL when there's no authenticated tenant. */
db: TenantScopedClient | null;
/** Authenticated user, or null. */
user: SessionUser | null;
/** Tenant id (convenience). */
tenantId: string | null;
headers: Headers;
logger: typeof logger;
};
export async function createTRPCContext({ user, headers }: CreateContextOptions): Promise<Context> {
return {
prisma,
db: user ? tenantScoped(prisma, user.tenantId) : null,
user,
tenantId: user?.tenantId ?? null,
headers,
logger: logger.child({ tenantId: user?.tenantId ?? null, userId: user?.id ?? null }),
};
}

View File

@ -0,0 +1,3 @@
export { appRouter, type AppRouter } from './routers/_app';
export { createTRPCContext, type Context, type SessionUser } from './context';
export { createCallerFactory } from './trpc';

View File

@ -0,0 +1,11 @@
import pino from 'pino';
export const logger = pino({
level: process.env.LOG_LEVEL ?? 'info',
base: undefined,
// Pretty-print only in development; in production emit JSON for log aggregation.
transport:
process.env.NODE_ENV === 'development'
? { target: 'pino-pretty', options: { colorize: true, translateTime: 'SYS:HH:MM:ss' } }
: undefined,
});

View File

@ -0,0 +1,8 @@
import { router } from '../trpc';
import { pingRouter } from './ping';
export const appRouter = router({
ping: pingRouter,
});
export type AppRouter = typeof appRouter;

View File

@ -0,0 +1,31 @@
import { TRPCError } from '@trpc/server';
import { protectedProcedure, router } from '../trpc';
export const pingRouter = router({
/**
* End-to-end smoke test:
* client tRPC Prisma Postgres tenant fetched response.
* Returns the current tenant so the caller can confirm scoping works.
*/
ping: protectedProcedure.query(async ({ ctx }) => {
// Tenant is not in TENANT_SCOPED_MODELS so the extension passes this
// through; we still go via ctx.db to keep call sites uniform.
const tenant = await ctx.db.tenant.findUnique({
where: { id: ctx.tenantId },
select: { id: true, name: true },
});
if (!tenant) {
throw new TRPCError({
code: 'NOT_FOUND',
message: `Tenant ${ctx.tenantId} not found`,
});
}
return {
ok: true as const,
tenant,
timestamp: new Date().toISOString(),
};
}),
});

38
packages/api/src/trpc.ts Normal file
View File

@ -0,0 +1,38 @@
import { initTRPC, TRPCError } from '@trpc/server';
import superjson from 'superjson';
import { ZodError } from 'zod';
import type { Context } from './context';
const t = initTRPC.context<Context>().create({
transformer: superjson,
errorFormatter({ shape, error }) {
return {
...shape,
data: {
...shape.data,
zodError: error.cause instanceof ZodError ? error.cause.flatten() : null,
},
};
},
});
export const router = t.router;
export const createCallerFactory = t.createCallerFactory;
/** Public — no auth required. */
export const publicProcedure = t.procedure;
/** Protected — requires an authenticated session with a tenantId. */
export const protectedProcedure = t.procedure.use(({ ctx, next }) => {
if (!ctx.user || !ctx.db || !ctx.tenantId) {
throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Not authenticated' });
}
return next({
ctx: {
...ctx,
user: ctx.user,
db: ctx.db,
tenantId: ctx.tenantId,
},
});
});

View File

@ -0,0 +1,9 @@
{
"extends": "@repo/config/tsconfig/base.json",
"compilerOptions": {
"noEmit": true,
"module": "ESNext",
"moduleResolution": "Bundler"
},
"include": ["src/**/*.ts"]
}

View File

@ -0,0 +1,34 @@
import js from '@eslint/js';
import tseslint from 'typescript-eslint';
import prettier from 'eslint-config-prettier';
import globals from 'globals';
/** @type {import("eslint").Linter.Config[]} */
export default [
js.configs.recommended,
...tseslint.configs.recommended,
prettier,
{
languageOptions: {
ecmaVersion: 2022,
sourceType: 'module',
globals: {
...globals.node,
},
},
rules: {
'@typescript-eslint/no-unused-vars': [
'error',
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
],
'@typescript-eslint/consistent-type-imports': [
'warn',
{ prefer: 'type-imports', fixStyle: 'inline-type-imports' },
],
'no-console': ['warn', { allow: ['warn', 'error'] }],
},
},
{
ignores: ['dist/**', '.next/**', '.turbo/**', 'node_modules/**', 'coverage/**'],
},
];

View File

@ -0,0 +1,23 @@
import base from './base.js';
import nextPlugin from '@next/eslint-plugin-next';
import globals from 'globals';
/** @type {import("eslint").Linter.Config[]} */
export default [
...base,
{
plugins: {
'@next/next': nextPlugin,
},
languageOptions: {
globals: {
...globals.browser,
...globals.node,
},
},
rules: {
...nextPlugin.configs.recommended.rules,
...nextPlugin.configs['core-web-vitals'].rules,
},
},
];

View File

@ -0,0 +1,29 @@
{
"name": "@repo/config",
"version": "0.0.0",
"private": true,
"type": "module",
"exports": {
"./tsconfig/base.json": "./tsconfig/base.json",
"./tsconfig/nextjs.json": "./tsconfig/nextjs.json",
"./tsconfig/library.json": "./tsconfig/library.json",
"./eslint/base": "./eslint/base.js",
"./eslint/nextjs": "./eslint/nextjs.js",
"./tailwind/preset": "./tailwind/preset.cjs"
},
"files": [
"tsconfig",
"eslint",
"tailwind"
],
"dependencies": {
"@eslint/js": "^9.17.0",
"@next/eslint-plugin-next": "^15.1.3",
"eslint": "^9.17.0",
"eslint-config-prettier": "^9.1.0",
"globals": "^15.14.0",
"tailwindcss": "^3.4.17",
"tailwindcss-animate": "^1.0.7",
"typescript-eslint": "^8.19.0"
}
}

View File

@ -0,0 +1,63 @@
/**
* Shared Tailwind v3 preset used by all apps and the UI package.
* shadcn/ui design tokens live here (HSL CSS variables consumed in globals.css).
*/
const animate = require('tailwindcss-animate');
/** @type {import("tailwindcss").Config} */
module.exports = {
darkMode: ['class'],
content: [],
theme: {
container: {
center: true,
padding: '1rem',
screens: {
'2xl': '1400px',
},
},
extend: {
colors: {
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))',
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))',
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))',
},
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))',
},
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))',
},
popover: {
DEFAULT: 'hsl(var(--popover))',
foreground: 'hsl(var(--popover-foreground))',
},
card: {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))',
},
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)',
},
},
},
plugins: [animate],
};

View File

@ -0,0 +1,23 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"display": "Base",
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022"],
"module": "ESNext",
"moduleResolution": "Bundler",
"moduleDetection": "force",
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"isolatedModules": true,
"verbatimModuleSyntax": false,
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"noFallthroughCasesInSwitch": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true
},
"exclude": ["node_modules", "dist", ".next", ".turbo"]
}

View File

@ -0,0 +1,13 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"display": "Library",
"extends": "./base.json",
"compilerOptions": {
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "dist",
"rootDir": "src"
},
"include": ["src/**/*.ts", "src/**/*.tsx"]
}

View File

@ -0,0 +1,15 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"display": "Next.js",
"extends": "./base.json",
"compilerOptions": {
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"jsx": "preserve",
"allowJs": true,
"noEmit": true,
"incremental": true,
"plugins": [{ "name": "next" }]
},
"include": ["**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules", ".next", "dist"]
}

28
packages/db/package.json Normal file
View File

@ -0,0 +1,28 @@
{
"name": "@repo/db",
"version": "0.0.0",
"private": true,
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"postinstall": "prisma generate",
"seed": "tsx prisma/seed.ts",
"typecheck": "tsc --noEmit",
"clean": "rimraf .turbo node_modules"
},
"dependencies": {
"@prisma/client": "^6.1.0"
},
"devDependencies": {
"@repo/config": "workspace:*",
"dotenv": "^16.4.7",
"prisma": "^6.1.0",
"rimraf": "^6.0.1",
"tsx": "^4.19.2",
"typescript": "^5.7.2"
}
}

View File

@ -0,0 +1,17 @@
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { config as loadEnv } from 'dotenv';
import { defineConfig } from 'prisma/config';
// Load the repo-root .env so DATABASE_URL is visible when Prisma CLI runs
// from inside packages/db. The .env file is intentionally kept at the repo
// root (single source of truth, gitignored).
const here = path.dirname(fileURLToPath(import.meta.url));
loadEnv({ path: path.resolve(here, '../../.env') });
export default defineConfig({
schema: path.join('prisma', 'schema.prisma'),
migrations: {
seed: 'tsx prisma/seed.ts',
},
});

View File

@ -0,0 +1,78 @@
-- CreateEnum
CREATE TYPE "UserRole" AS ENUM ('ADMIN', 'SUPERVISOR', 'OPERATOR');
-- CreateTable
CREATE TABLE "Tenant" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Tenant_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"email" TEXT NOT NULL,
"passwordHash" TEXT,
"role" "UserRole" NOT NULL DEFAULT 'OPERATOR',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Workstation" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"code" TEXT NOT NULL,
"name" TEXT NOT NULL,
"area" TEXT NOT NULL,
CONSTRAINT "Workstation_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "DomainEvent" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"aggregateType" TEXT NOT NULL,
"aggregateId" TEXT NOT NULL,
"eventType" TEXT NOT NULL,
"payload" JSONB NOT NULL,
"occurredAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"processedAt" TIMESTAMP(3),
CONSTRAINT "DomainEvent_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "User_tenantId_idx" ON "User"("tenantId");
-- CreateIndex
CREATE UNIQUE INDEX "User_tenantId_email_key" ON "User"("tenantId", "email");
-- CreateIndex
CREATE INDEX "Workstation_tenantId_idx" ON "Workstation"("tenantId");
-- CreateIndex
CREATE UNIQUE INDEX "Workstation_tenantId_code_key" ON "Workstation"("tenantId", "code");
-- CreateIndex
CREATE INDEX "DomainEvent_tenantId_idx" ON "DomainEvent"("tenantId");
-- CreateIndex
CREATE INDEX "DomainEvent_tenantId_processedAt_idx" ON "DomainEvent"("tenantId", "processedAt");
-- CreateIndex
CREATE INDEX "DomainEvent_tenantId_aggregateType_aggregateId_idx" ON "DomainEvent"("tenantId", "aggregateType", "aggregateId");
-- AddForeignKey
ALTER TABLE "User" ADD CONSTRAINT "User_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Workstation" ADD CONSTRAINT "Workstation_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "DomainEvent" ADD CONSTRAINT "DomainEvent_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"

View File

@ -0,0 +1,74 @@
// FieldOps — initial scaffold schema.
//
// All models except Tenant carry tenantId. Tenant scoping is enforced at runtime
// by the Prisma extension in src/tenant-extension.ts — see that file's header for
// the operations it covers and (more importantly) those it does NOT.
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
enum UserRole {
ADMIN
SUPERVISOR
OPERATOR
}
model Tenant {
id String @id @default(cuid())
name String
createdAt DateTime @default(now())
users User[]
workstations Workstation[]
events DomainEvent[]
}
model User {
id String @id @default(cuid())
tenantId String
email String
passwordHash String?
role UserRole @default(OPERATOR)
createdAt DateTime @default(now())
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
@@unique([tenantId, email])
@@index([tenantId])
}
model Workstation {
id String @id @default(cuid())
tenantId String
code String
name String
area String
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
@@unique([tenantId, code])
@@index([tenantId])
}
model DomainEvent {
id String @id @default(cuid())
tenantId String
aggregateType String
aggregateId String
eventType String
payload Json
occurredAt DateTime @default(now())
processedAt DateTime?
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
@@index([tenantId])
@@index([tenantId, processedAt])
@@index([tenantId, aggregateType, aggregateId])
}

View File

@ -0,0 +1,55 @@
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { config as loadEnv } from 'dotenv';
// Load repo-root .env so DATABASE_URL is visible when this script runs from
// any CWD (pnpm invokes it from packages/db).
const here = path.dirname(fileURLToPath(import.meta.url));
loadEnv({ path: path.resolve(here, '../../../.env') });
const { PrismaClient, UserRole } = await import('@prisma/client');
const prisma = new PrismaClient();
const DEMO_TENANT_NAME = 'Demo Factory';
const DEMO_ADMIN_EMAIL = 'admin@demo.local';
async function main() {
// Idempotent: if a prior run created the demo tenant, wipe it and recreate.
// Cascade deletes on the relations handle the children.
const existing = await prisma.tenant.findFirst({ where: { name: DEMO_TENANT_NAME } });
if (existing) {
await prisma.tenant.delete({ where: { id: existing.id } });
}
const tenant = await prisma.tenant.create({
data: { name: DEMO_TENANT_NAME },
});
await prisma.user.create({
data: {
tenantId: tenant.id,
email: DEMO_ADMIN_EMAIL,
role: UserRole.ADMIN,
},
});
await prisma.workstation.createMany({
data: [
{ tenantId: tenant.id, code: 'WS-001', name: 'Assembly A', area: 'Floor 1' },
{ tenantId: tenant.id, code: 'WS-002', name: 'Packaging B', area: 'Floor 2' },
],
});
console.warn(
`Seed complete — tenant=${tenant.id} (${tenant.name}), admin=${DEMO_ADMIN_EMAIL}, workstations=2`,
);
}
main()
.catch((err) => {
console.error('Seed failed:', err);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});

24
packages/db/src/client.ts Normal file
View File

@ -0,0 +1,24 @@
import { PrismaClient } from '@prisma/client';
/**
* Singleton PrismaClient. In dev, Next's HMR causes module re-evaluation; the
* globalThis cache prevents leaking a new client per reload.
*
* This is the UNSCOPED root client. Application code MUST NOT use it directly
* for tenant-scoped reads/writes always pass it through `tenantScoped(...)`
* with the tenantId from the request context.
*/
const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient };
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
log: process.env.NODE_ENV === 'development' ? ['warn', 'error'] : ['error'],
});
if (process.env.NODE_ENV !== 'production') {
globalForPrisma.prisma = prisma;
}
export type DbClient = typeof prisma;

4
packages/db/src/index.ts Normal file
View File

@ -0,0 +1,4 @@
export { prisma, type DbClient } from './client';
export { tenantScoped, type TenantScopedClient } from './tenant-extension';
export { Prisma, UserRole } from '@prisma/client';
export type { User, Tenant, Workstation, DomainEvent } from '@prisma/client';

View File

@ -0,0 +1,180 @@
import type { PrismaClient } from '@prisma/client';
/**
* ============================================================================
* Multi-tenant Prisma extension
* ============================================================================
*
* PURPOSE
* -------
* Guarantee that every read and write against a tenant-scoped model is
* filtered/stamped with the current tenantId, so application code can never
* accidentally cross tenant boundaries. Call this once per request from the
* tRPC context, passing the tenantId resolved from the authenticated session.
*
* const db = tenantScoped(prisma, ctx.tenantId);
* await db.workstation.findMany(); // implicitly WHERE tenantId = ctx.tenantId
*
* HOW TO EXTEND THIS
* ------------------
* Adding a new tenant-scoped model:
* 1. Add `tenantId String` + `@@index([tenantId])` in schema.prisma.
* 2. Add a relation to Tenant with `onDelete: Cascade`.
* 3. Add the model's PascalCase name to TENANT_SCOPED_MODELS below.
* 4. Run `pnpm db:migrate`.
*
* OPERATIONS INTERCEPTED
* ----------------------
* Reads : findFirst, findFirstOrThrow, findMany, count, aggregate, groupBy
* Writes : create, createMany, createManyAndReturn, update, updateMany,
* upsert, delete, deleteMany
* Special : findUnique / findUniqueOrThrow are DOWNGRADED to
* findFirst / findFirstOrThrow + tenantId filter.
*
* Reason: Prisma's findUnique only accepts where clauses that match
* a declared unique constraint. Adding tenantId to the where would
* either (a) require every @unique to be redeclared as
* @@unique([tenantId, ...]), or (b) fail Prisma's validation. The
* downgrade preserves tenant isolation at the cost of the runtime
* guarantee that the result is unique. If you specifically need
* "throw on multiple", use `findFirstOrThrow` with a where clause
* that you know is unique within the tenant (most commonly the
* compound `(tenantId, id)`).
*
* OPERATIONS NOT INTERCEPTED THESE BYPASS TENANT SCOPING
* --------------------------------------------------------
* The following are intentionally left alone. They are the known holes in this
* guarantee and MUST be audited at PR-review time:
*
* 1. $queryRaw, $queryRawUnsafe, $executeRaw, $executeRawUnsafe
* Raw SQL bypasses the extension entirely. Always include the tenant
* filter explicitly:
* await db.$queryRaw`SELECT ... FROM "Workstation" WHERE "tenantId" = ${tenantId}`;
* Use raw SQL only for migrations, admin tooling, or aggregations the
* Prisma query engine cannot express.
*
* 2. $transaction (interactive callback form) when the callback receives a
* client OTHER than the scoped one returned by this function.
* The extension does not re-wrap the inner transactional client. Either:
* - issue all operations through the outer scoped client and use the
* sequential array form: `await db.$transaction([op1, op2])`, OR
* - if you need the interactive form, scope explicitly:
* await prisma.$transaction(async (tx) => {
* const scopedTx = tenantScoped(tx as PrismaClient, tenantId);
* ...
* });
*
* 3. Models without tenantId (currently only `Tenant`). These are passed
* through unchanged. Code touching Tenant must take care not to leak
* cross-tenant data through joins or includes from the tenant side.
*
* INVARIANTS THIS EXTENSION ENFORCES
* ----------------------------------
* - On every intercepted read, `where.tenantId` is set to the bound tenantId,
* OVERWRITING any tenantId the caller may have supplied. This is on purpose:
* callers must not be able to read other tenants' data even by mistake.
* - On `create` and `createMany`, the bound tenantId is injected into `data`,
* OVERWRITING any value the caller supplied same reason.
* - On `update` and `upsert.update`, any attempt to set `tenantId` is silently
* dropped. Records cannot be re-homed across tenants through this path.
*
* CHANGELOG
* ---------
* 2026-05-16 initial version (scaffold).
* ============================================================================
*/
const TENANT_SCOPED_MODELS = ['User', 'Workstation', 'DomainEvent'] as const;
type TenantScopedModel = (typeof TENANT_SCOPED_MODELS)[number];
function isTenantScoped(model: string | undefined): model is TenantScopedModel {
return !!model && (TENANT_SCOPED_MODELS as readonly string[]).includes(model);
}
function modelAccessor(model: string): string {
return model.charAt(0).toLowerCase() + model.slice(1);
}
export function tenantScoped(prisma: PrismaClient, tenantId: string) {
return prisma.$extends({
name: 'tenant-scope',
query: {
$allModels: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async $allOperations({ model, operation, args, query }: any) {
if (!isTenantScoped(model)) {
return query(args);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const a = args as any;
switch (operation) {
case 'findFirst':
case 'findFirstOrThrow':
case 'findMany':
case 'count':
case 'aggregate':
case 'groupBy':
case 'updateMany':
case 'deleteMany': {
a.where = { ...(a.where ?? {}), tenantId };
return query(a);
}
case 'findUnique':
case 'findUniqueOrThrow': {
// Downgrade to findFirst[OrThrow]. See header.
const target =
operation === 'findUnique' ? 'findFirst' : 'findFirstOrThrow';
const where = { ...(a.where ?? {}), tenantId };
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const delegate = (prisma as any)[modelAccessor(model)];
return delegate[target]({ ...a, where });
}
case 'create': {
a.data = { ...(a.data ?? {}), tenantId };
return query(a);
}
case 'createMany':
case 'createManyAndReturn': {
const data = a.data;
if (Array.isArray(data)) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
a.data = data.map((d: any) => ({ ...d, tenantId }));
} else {
a.data = { ...(data ?? {}), tenantId };
}
return query(a);
}
case 'update': {
a.where = { ...(a.where ?? {}), tenantId };
if (a.data && 'tenantId' in a.data) delete a.data.tenantId;
return query(a);
}
case 'upsert': {
a.where = { ...(a.where ?? {}), tenantId };
a.create = { ...(a.create ?? {}), tenantId };
if (a.update && 'tenantId' in a.update) delete a.update.tenantId;
return query(a);
}
case 'delete': {
a.where = { ...(a.where ?? {}), tenantId };
return query(a);
}
default:
return query(args);
}
},
},
},
});
}
export type TenantScopedClient = ReturnType<typeof tenantScoped>;

View File

@ -0,0 +1,9 @@
{
"extends": "@repo/config/tsconfig/base.json",
"compilerOptions": {
"noEmit": true,
"module": "ESNext",
"moduleResolution": "Bundler"
},
"include": ["src/**/*.ts", "prisma/**/*.ts"]
}

View File

@ -0,0 +1,20 @@
{
"name": "@repo/domain",
"version": "0.0.0",
"private": true,
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"typecheck": "tsc --noEmit",
"clean": "rimraf .turbo node_modules"
},
"devDependencies": {
"@repo/config": "workspace:*",
"rimraf": "^6.0.1",
"typescript": "^5.7.2"
}
}

View File

@ -0,0 +1,4 @@
// Pure domain logic lives here — no I/O, no framework imports.
// Intentionally empty in this scaffold phase; populate as business rules emerge.
export {};

View File

@ -0,0 +1,9 @@
{
"extends": "@repo/config/tsconfig/base.json",
"compilerOptions": {
"noEmit": true,
"module": "ESNext",
"moduleResolution": "Bundler"
},
"include": ["src/**/*.ts"]
}

37
packages/ui/package.json Normal file
View File

@ -0,0 +1,37 @@
{
"name": "@repo/ui",
"version": "0.0.0",
"private": true,
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts",
"./styles.css": "./src/styles.css",
"./lib/utils": "./src/lib/utils.ts",
"./components/*": "./src/components/*.tsx"
},
"scripts": {
"typecheck": "tsc --noEmit",
"clean": "rimraf .turbo node_modules"
},
"dependencies": {
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.469.0",
"tailwind-merge": "^2.5.5"
},
"peerDependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@repo/config": "workspace:*",
"@types/react": "^19.0.2",
"@types/react-dom": "^19.0.2",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"rimraf": "^6.0.1",
"typescript": "^5.7.2"
}
}

View File

@ -0,0 +1,47 @@
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '../lib/utils';
const alertVariants = cva(
'relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground',
{
variants: {
variant: {
default: 'bg-background text-foreground',
destructive:
'border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive',
},
},
defaultVariants: {
variant: 'default',
},
},
);
export const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div ref={ref} role="alert" className={cn(alertVariants({ variant }), className)} {...props} />
));
Alert.displayName = 'Alert';
export const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn('mb-1 font-medium leading-none tracking-tight', className)}
{...props}
/>
));
AlertTitle.displayName = 'AlertTitle';
export const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn('text-sm [&_p]:leading-relaxed', className)} {...props} />
));
AlertDescription.displayName = 'AlertDescription';

View File

@ -0,0 +1,42 @@
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '../lib/utils';
const buttonVariants = cva(
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-10 px-4 py-2',
sm: 'h-9 rounded-md px-3',
lg: 'h-11 rounded-md px-8',
icon: 'h-10 w-10',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {}
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, ...props }, ref) => (
<button className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
),
);
Button.displayName = 'Button';
export { buttonVariants };

View File

@ -0,0 +1,54 @@
import * as React from 'react';
import { cn } from '../lib/utils';
export const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('rounded-lg border bg-card text-card-foreground shadow-sm', className)}
{...props}
/>
),
);
Card.displayName = 'Card';
export const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex flex-col space-y-1.5 p-6', className)} {...props} />
),
);
CardHeader.displayName = 'CardHeader';
export const CardTitle = React.forwardRef<
HTMLHeadingElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn('text-2xl font-semibold leading-none tracking-tight', className)}
{...props}
/>
));
CardTitle.displayName = 'CardTitle';
export const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
));
CardDescription.displayName = 'CardDescription';
export const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
),
);
CardContent.displayName = 'CardContent';
export const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex items-center p-6 pt-0', className)} {...props} />
),
);
CardFooter.displayName = 'CardFooter';

11
packages/ui/src/index.ts Normal file
View File

@ -0,0 +1,11 @@
export { cn } from './lib/utils';
export { Button, buttonVariants, type ButtonProps } from './components/button';
export {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
CardFooter,
} from './components/card';
export { Alert, AlertTitle, AlertDescription } from './components/alert';

View File

@ -0,0 +1,6 @@
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

View File

@ -0,0 +1,61 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
--radius: 0.5rem;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9%;
}
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
font-feature-settings:
'rlig' 1,
'calt' 1;
}
}

11
packages/ui/tsconfig.json Normal file
View File

@ -0,0 +1,11 @@
{
"extends": "@repo/config/tsconfig/base.json",
"compilerOptions": {
"noEmit": true,
"jsx": "preserve",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "Bundler"
},
"include": ["src/**/*.ts", "src/**/*.tsx"]
}

3663
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

17
pnpm-workspace.yaml Normal file
View File

@ -0,0 +1,17 @@
packages:
- "apps/*"
- "packages/*"
- "e2e"
# pnpm 11 blocks postinstall scripts by default for security. We explicitly
# approve only the packages that genuinely need them:
# - @prisma/client / @prisma/engines / prisma: generate the Prisma client
# and download the matching query engine binary.
# - esbuild: prebuilt native binary; used transitively by tsx.
# - sharp: prebuilt native binary; used by Next.js image optimization.
allowBuilds:
'@prisma/client': true
'@prisma/engines': true
esbuild: true
prisma: true
sharp: true

13
tsconfig.json Normal file
View File

@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"noEmit": true
},
"exclude": ["node_modules", "**/node_modules", "**/dist", "**/.next", "**/build"]
}

41
turbo.json Normal file
View File

@ -0,0 +1,41 @@
{
"$schema": "https://turbo.build/schema.json",
"globalDependencies": [".env"],
"globalEnv": [
"NODE_ENV",
"DATABASE_URL",
"AUTH_SECRET",
"AUTH_DEV_AUTOLOGIN",
"NEXT_PUBLIC_APP_URL",
"LOG_LEVEL"
],
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": [".next/**", "!.next/cache/**", "dist/**"]
},
"dev": {
"cache": false,
"persistent": true
},
"lint": {
"dependsOn": ["^build"],
"outputs": []
},
"typecheck": {
"dependsOn": ["^build"],
"outputs": []
},
"test": {
"dependsOn": ["^build"],
"outputs": ["coverage/**"]
},
"clean": {
"cache": false
},
"db:generate": {
"cache": false,
"outputs": ["node_modules/.prisma/**"]
}
}
}