first project commit
This commit is contained in:
parent
33789b13d1
commit
c013b52f59
15
.claude/settings.local.json
Normal file
15
.claude/settings.local.json
Normal 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
33
.env.example
Normal 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
5
.npmrc
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
engine-strict=true
|
||||||
|
auto-install-peers=true
|
||||||
|
strict-peer-dependencies=false
|
||||||
|
prefer-workspace-packages=true
|
||||||
|
link-workspace-packages=true
|
||||||
11
.prettierignore
Normal file
11
.prettierignore
Normal 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
12
.prettierrc.json
Normal 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"]
|
||||||
|
}
|
||||||
25
apps/admin-web/app/layout.tsx
Normal file
25
apps/admin-web/app/layout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
8
apps/admin-web/app/page.tsx
Normal file
8
apps/admin-web/app/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
8
apps/admin-web/next.config.ts
Normal file
8
apps/admin-web/next.config.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import type { NextConfig } from 'next';
|
||||||
|
|
||||||
|
const config: NextConfig = {
|
||||||
|
reactStrictMode: true,
|
||||||
|
poweredByHeader: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
28
apps/admin-web/package.json
Normal file
28
apps/admin-web/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
9
apps/admin-web/tsconfig.json
Normal file
9
apps/admin-web/tsconfig.json
Normal 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"]
|
||||||
|
}
|
||||||
4
apps/operator-pwa/app/api/auth/[...nextauth]/route.ts
Normal file
4
apps/operator-pwa/app/api/auth/[...nextauth]/route.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
import { handlers } from '@/lib/auth';
|
||||||
|
|
||||||
|
export const { GET, POST } = handlers;
|
||||||
|
export const runtime = 'nodejs';
|
||||||
25
apps/operator-pwa/app/api/trpc/[trpc]/route.ts
Normal file
25
apps/operator-pwa/app/api/trpc/[trpc]/route.ts
Normal 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 };
|
||||||
58
apps/operator-pwa/app/globals.css
Normal file
58
apps/operator-pwa/app/globals.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
31
apps/operator-pwa/app/layout.tsx
Normal file
31
apps/operator-pwa/app/layout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
88
apps/operator-pwa/app/page.tsx
Normal file
88
apps/operator-pwa/app/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
43
apps/operator-pwa/app/ping-client.tsx
Normal file
43
apps/operator-pwa/app/ping-client.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
39
apps/operator-pwa/app/providers.tsx
Normal file
39
apps/operator-pwa/app/providers.tsx
Normal 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
36
apps/operator-pwa/env.ts
Normal 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,
|
||||||
|
});
|
||||||
40
apps/operator-pwa/lib/auth.config.ts
Normal file
40
apps/operator-pwa/lib/auth.config.ts
Normal 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;
|
||||||
93
apps/operator-pwa/lib/auth.ts
Normal file
93
apps/operator-pwa/lib/auth.ts
Normal 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;
|
||||||
|
}
|
||||||
12
apps/operator-pwa/lib/trpc/client.ts
Normal file
12
apps/operator-pwa/lib/trpc/client.ts
Normal 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>();
|
||||||
30
apps/operator-pwa/lib/trpc/server.ts
Normal file
30
apps/operator-pwa/lib/trpc/server.ts
Normal 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>;
|
||||||
17
apps/operator-pwa/middleware.ts
Normal file
17
apps/operator-pwa/middleware.ts
Normal 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).*)',
|
||||||
|
],
|
||||||
|
};
|
||||||
14
apps/operator-pwa/next.config.ts
Normal file
14
apps/operator-pwa/next.config.ts
Normal 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;
|
||||||
46
apps/operator-pwa/package.json
Normal file
46
apps/operator-pwa/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
apps/operator-pwa/postcss.config.cjs
Normal file
6
apps/operator-pwa/postcss.config.cjs
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
4
apps/operator-pwa/public/icon-192.svg
Normal file
4
apps/operator-pwa/public/icon-192.svg
Normal 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 |
4
apps/operator-pwa/public/icon-512.svg
Normal file
4
apps/operator-pwa/public/icon-512.svg
Normal 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 |
4
apps/operator-pwa/public/icon-maskable.svg
Normal file
4
apps/operator-pwa/public/icon-maskable.svg
Normal 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 |
30
apps/operator-pwa/public/manifest.webmanifest
Normal file
30
apps/operator-pwa/public/manifest.webmanifest
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
14
apps/operator-pwa/tailwind.config.ts
Normal file
14
apps/operator-pwa/tailwind.config.ts
Normal 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;
|
||||||
11
apps/operator-pwa/tsconfig.json
Normal file
11
apps/operator-pwa/tsconfig.json
Normal 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
23
docker-compose.yml
Normal 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
21
e2e/package.json
Normal 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
40
e2e/playwright.config.ts
Normal 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
18
e2e/tests/ping.spec.ts
Normal 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
10
e2e/tsconfig.json
Normal 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
36
package.json
Normal 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
29
packages/api/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
45
packages/api/src/context.ts
Normal file
45
packages/api/src/context.ts
Normal 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 }),
|
||||||
|
};
|
||||||
|
}
|
||||||
3
packages/api/src/index.ts
Normal file
3
packages/api/src/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export { appRouter, type AppRouter } from './routers/_app';
|
||||||
|
export { createTRPCContext, type Context, type SessionUser } from './context';
|
||||||
|
export { createCallerFactory } from './trpc';
|
||||||
11
packages/api/src/logger.ts
Normal file
11
packages/api/src/logger.ts
Normal 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,
|
||||||
|
});
|
||||||
8
packages/api/src/routers/_app.ts
Normal file
8
packages/api/src/routers/_app.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import { router } from '../trpc';
|
||||||
|
import { pingRouter } from './ping';
|
||||||
|
|
||||||
|
export const appRouter = router({
|
||||||
|
ping: pingRouter,
|
||||||
|
});
|
||||||
|
|
||||||
|
export type AppRouter = typeof appRouter;
|
||||||
31
packages/api/src/routers/ping.ts
Normal file
31
packages/api/src/routers/ping.ts
Normal 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
38
packages/api/src/trpc.ts
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
9
packages/api/tsconfig.json
Normal file
9
packages/api/tsconfig.json
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "@repo/config/tsconfig/base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"noEmit": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler"
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"]
|
||||||
|
}
|
||||||
34
packages/config/eslint/base.js
Normal file
34
packages/config/eslint/base.js
Normal 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/**'],
|
||||||
|
},
|
||||||
|
];
|
||||||
23
packages/config/eslint/nextjs.js
Normal file
23
packages/config/eslint/nextjs.js
Normal 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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
29
packages/config/package.json
Normal file
29
packages/config/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
63
packages/config/tailwind/preset.cjs
Normal file
63
packages/config/tailwind/preset.cjs
Normal 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],
|
||||||
|
};
|
||||||
23
packages/config/tsconfig/base.json
Normal file
23
packages/config/tsconfig/base.json
Normal 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"]
|
||||||
|
}
|
||||||
13
packages/config/tsconfig/library.json
Normal file
13
packages/config/tsconfig/library.json
Normal 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"]
|
||||||
|
}
|
||||||
15
packages/config/tsconfig/nextjs.json
Normal file
15
packages/config/tsconfig/nextjs.json
Normal 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
28
packages/db/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
17
packages/db/prisma.config.ts
Normal file
17
packages/db/prisma.config.ts
Normal 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',
|
||||||
|
},
|
||||||
|
});
|
||||||
@ -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;
|
||||||
3
packages/db/prisma/migrations/migration_lock.toml
Normal file
3
packages/db/prisma/migrations/migration_lock.toml
Normal 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"
|
||||||
74
packages/db/prisma/schema.prisma
Normal file
74
packages/db/prisma/schema.prisma
Normal 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])
|
||||||
|
}
|
||||||
55
packages/db/prisma/seed.ts
Normal file
55
packages/db/prisma/seed.ts
Normal 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
24
packages/db/src/client.ts
Normal 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
4
packages/db/src/index.ts
Normal 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';
|
||||||
180
packages/db/src/tenant-extension.ts
Normal file
180
packages/db/src/tenant-extension.ts
Normal 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>;
|
||||||
9
packages/db/tsconfig.json
Normal file
9
packages/db/tsconfig.json
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "@repo/config/tsconfig/base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"noEmit": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler"
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "prisma/**/*.ts"]
|
||||||
|
}
|
||||||
20
packages/domain/package.json
Normal file
20
packages/domain/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
4
packages/domain/src/index.ts
Normal file
4
packages/domain/src/index.ts
Normal 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 {};
|
||||||
9
packages/domain/tsconfig.json
Normal file
9
packages/domain/tsconfig.json
Normal 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
37
packages/ui/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
47
packages/ui/src/components/alert.tsx
Normal file
47
packages/ui/src/components/alert.tsx
Normal 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';
|
||||||
42
packages/ui/src/components/button.tsx
Normal file
42
packages/ui/src/components/button.tsx
Normal 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 };
|
||||||
54
packages/ui/src/components/card.tsx
Normal file
54
packages/ui/src/components/card.tsx
Normal 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
11
packages/ui/src/index.ts
Normal 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';
|
||||||
6
packages/ui/src/lib/utils.ts
Normal file
6
packages/ui/src/lib/utils.ts
Normal 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));
|
||||||
|
}
|
||||||
61
packages/ui/src/styles.css
Normal file
61
packages/ui/src/styles.css
Normal 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
11
packages/ui/tsconfig.json
Normal 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
3663
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
17
pnpm-workspace.yaml
Normal file
17
pnpm-workspace.yaml
Normal 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
13
tsconfig.json
Normal 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
41
turbo.json
Normal 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/**"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user