diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..70a4a2b --- /dev/null +++ b/.claude/settings.local.json @@ -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)" + ] + } +} diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..d134944 --- /dev/null +++ b/.env.example @@ -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" diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..ffbc55c --- /dev/null +++ b/.npmrc @@ -0,0 +1,5 @@ +engine-strict=true +auto-install-peers=true +strict-peer-dependencies=false +prefer-workspace-packages=true +link-workspace-packages=true diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..2bd5a0a --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +22 diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..0c0e4dc --- /dev/null +++ b/.prettierignore @@ -0,0 +1,11 @@ +node_modules +.next +.turbo +dist +build +coverage +playwright-report +test-results +pnpm-lock.yaml +*.tsbuildinfo +packages/db/prisma/migrations diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..5e90b1f --- /dev/null +++ b/.prettierrc.json @@ -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"] +} diff --git a/apps/admin-web/app/layout.tsx b/apps/admin-web/app/layout.tsx new file mode 100644 index 0000000..45237d1 --- /dev/null +++ b/apps/admin-web/app/layout.tsx @@ -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 ( + + + {children} + + + ); +} diff --git a/apps/admin-web/app/page.tsx b/apps/admin-web/app/page.tsx new file mode 100644 index 0000000..81ac7a9 --- /dev/null +++ b/apps/admin-web/app/page.tsx @@ -0,0 +1,8 @@ +export default function Page() { + return ( +
+

FieldOps Admin

+

Coming soon.

+
+ ); +} diff --git a/apps/admin-web/next.config.ts b/apps/admin-web/next.config.ts new file mode 100644 index 0000000..2ae088f --- /dev/null +++ b/apps/admin-web/next.config.ts @@ -0,0 +1,8 @@ +import type { NextConfig } from 'next'; + +const config: NextConfig = { + reactStrictMode: true, + poweredByHeader: false, +}; + +export default config; diff --git a/apps/admin-web/package.json b/apps/admin-web/package.json new file mode 100644 index 0000000..ba87591 --- /dev/null +++ b/apps/admin-web/package.json @@ -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" + } +} diff --git a/apps/admin-web/tsconfig.json b/apps/admin-web/tsconfig.json new file mode 100644 index 0000000..469994a --- /dev/null +++ b/apps/admin-web/tsconfig.json @@ -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"] +} diff --git a/apps/operator-pwa/app/api/auth/[...nextauth]/route.ts b/apps/operator-pwa/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000..8f4b86d --- /dev/null +++ b/apps/operator-pwa/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,4 @@ +import { handlers } from '@/lib/auth'; + +export const { GET, POST } = handlers; +export const runtime = 'nodejs'; diff --git a/apps/operator-pwa/app/api/trpc/[trpc]/route.ts b/apps/operator-pwa/app/api/trpc/[trpc]/route.ts new file mode 100644 index 0000000..5a621cd --- /dev/null +++ b/apps/operator-pwa/app/api/trpc/[trpc]/route.ts @@ -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 ?? ''}:`, error.message); + } + }, + }); +}; + +export { handler as GET, handler as POST }; diff --git a/apps/operator-pwa/app/globals.css b/apps/operator-pwa/app/globals.css new file mode 100644 index 0000000..010a5f2 --- /dev/null +++ b/apps/operator-pwa/app/globals.css @@ -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; + } +} diff --git a/apps/operator-pwa/app/layout.tsx b/apps/operator-pwa/app/layout.tsx new file mode 100644 index 0000000..92ce506 --- /dev/null +++ b/apps/operator-pwa/app/layout.tsx @@ -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 ( + + + {children} + + + ); +} diff --git a/apps/operator-pwa/app/page.tsx b/apps/operator-pwa/app/page.tsx new file mode 100644 index 0000000..fe3817e --- /dev/null +++ b/apps/operator-pwa/app/page.tsx @@ -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> } + | { 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 ( +
+
+

FieldOps Operator

+

Scaffold smoke test

+
+ + {result.ok ? ( + + + + + Connected + + + End-to-end path verified: RSC → tRPC → Prisma → Postgres. + + + +
+ Tenant: + {result.payload.tenant.name} +
+
+ id: {result.payload.tenant.id} +
+
+ at: {result.payload.timestamp} +
+
+
+ ) : ( + + + Ping failed ({result.code}) + +

{result.message}

+

+ If this says UNAUTHORIZED, set{' '} + AUTH_DEV_AUTOLOGIN=true in .env for local dev, + or sign in via Auth.js. +

+
+
+ )} + + +
+ ); +} diff --git a/apps/operator-pwa/app/ping-client.tsx b/apps/operator-pwa/app/ping-client.tsx new file mode 100644 index 0000000..f16e25f --- /dev/null +++ b/apps/operator-pwa/app/ping-client.tsx @@ -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 ( + + + + {query.isPending ? ( + + ) : query.isError ? ( + + ) : ( + + )} + Client-side ping (useQuery) + + Round-trips through /api/trpc. + + + {query.isPending && Loading…} + {query.isError && ( + + {query.error.message} + + )} + {query.data && ( + tenant: {query.data.tenant.name} + )} + + + ); +} diff --git a/apps/operator-pwa/app/providers.tsx b/apps/operator-pwa/app/providers.tsx new file mode 100644 index 0000000..d192733 --- /dev/null +++ b/apps/operator-pwa/app/providers.tsx @@ -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 ( + + {children} + + ); +} diff --git a/apps/operator-pwa/env.ts b/apps/operator-pwa/env.ts new file mode 100644 index 0000000..e60d232 --- /dev/null +++ b/apps/operator-pwa/env.ts @@ -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, +}); diff --git a/apps/operator-pwa/lib/auth.config.ts b/apps/operator-pwa/lib/auth.config.ts new file mode 100644 index 0000000..d0a3748 --- /dev/null +++ b/apps/operator-pwa/lib/auth.config.ts @@ -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; diff --git a/apps/operator-pwa/lib/auth.ts b/apps/operator-pwa/lib/auth.ts new file mode 100644 index 0000000..4d4076d --- /dev/null +++ b/apps/operator-pwa/lib/auth.ts @@ -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 { + 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; +} diff --git a/apps/operator-pwa/lib/trpc/client.ts b/apps/operator-pwa/lib/trpc/client.ts new file mode 100644 index 0000000..72d6a94 --- /dev/null +++ b/apps/operator-pwa/lib/trpc/client.ts @@ -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(); diff --git a/apps/operator-pwa/lib/trpc/server.ts b/apps/operator-pwa/lib/trpc/server.ts new file mode 100644 index 0000000..7b919e0 --- /dev/null +++ b/apps/operator-pwa/lib/trpc/server.ts @@ -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; +export type RouterOutputs = inferRouterOutputs; diff --git a/apps/operator-pwa/middleware.ts b/apps/operator-pwa/middleware.ts new file mode 100644 index 0000000..1289609 --- /dev/null +++ b/apps/operator-pwa/middleware.ts @@ -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).*)', + ], +}; diff --git a/apps/operator-pwa/next.config.ts b/apps/operator-pwa/next.config.ts new file mode 100644 index 0000000..3cac97c --- /dev/null +++ b/apps/operator-pwa/next.config.ts @@ -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; diff --git a/apps/operator-pwa/package.json b/apps/operator-pwa/package.json new file mode 100644 index 0000000..81a7374 --- /dev/null +++ b/apps/operator-pwa/package.json @@ -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" + } +} diff --git a/apps/operator-pwa/postcss.config.cjs b/apps/operator-pwa/postcss.config.cjs new file mode 100644 index 0000000..12a703d --- /dev/null +++ b/apps/operator-pwa/postcss.config.cjs @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/apps/operator-pwa/public/icon-192.svg b/apps/operator-pwa/public/icon-192.svg new file mode 100644 index 0000000..93dc106 --- /dev/null +++ b/apps/operator-pwa/public/icon-192.svg @@ -0,0 +1,4 @@ + + + FO + diff --git a/apps/operator-pwa/public/icon-512.svg b/apps/operator-pwa/public/icon-512.svg new file mode 100644 index 0000000..e48e478 --- /dev/null +++ b/apps/operator-pwa/public/icon-512.svg @@ -0,0 +1,4 @@ + + + FO + diff --git a/apps/operator-pwa/public/icon-maskable.svg b/apps/operator-pwa/public/icon-maskable.svg new file mode 100644 index 0000000..146a7de --- /dev/null +++ b/apps/operator-pwa/public/icon-maskable.svg @@ -0,0 +1,4 @@ + + + FO + diff --git a/apps/operator-pwa/public/manifest.webmanifest b/apps/operator-pwa/public/manifest.webmanifest new file mode 100644 index 0000000..e1dca13 --- /dev/null +++ b/apps/operator-pwa/public/manifest.webmanifest @@ -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" + } + ] +} diff --git a/apps/operator-pwa/tailwind.config.ts b/apps/operator-pwa/tailwind.config.ts new file mode 100644 index 0000000..def781c --- /dev/null +++ b/apps/operator-pwa/tailwind.config.ts @@ -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; diff --git a/apps/operator-pwa/tsconfig.json b/apps/operator-pwa/tsconfig.json new file mode 100644 index 0000000..54f7609 --- /dev/null +++ b/apps/operator-pwa/tsconfig.json @@ -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"] +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..2ff1c61 --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/e2e/package.json b/e2e/package.json new file mode 100644 index 0000000..2d5f5e0 --- /dev/null +++ b/e2e/package.json @@ -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" + } +} diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts new file mode 100644 index 0000000..1471184 --- /dev/null +++ b/e2e/playwright.config.ts @@ -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', + }, + }, +}); diff --git a/e2e/tests/ping.spec.ts b/e2e/tests/ping.spec.ts new file mode 100644 index 0000000..da9871b --- /dev/null +++ b/e2e/tests/ping.spec.ts @@ -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'); + }); +}); diff --git a/e2e/tsconfig.json b/e2e/tsconfig.json new file mode 100644 index 0000000..b201f3b --- /dev/null +++ b/e2e/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "@repo/config/tsconfig/base.json", + "compilerOptions": { + "noEmit": true, + "lib": ["ES2022", "DOM"], + "module": "ESNext", + "moduleResolution": "Bundler" + }, + "include": ["**/*.ts"] +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..0310e14 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/packages/api/package.json b/packages/api/package.json new file mode 100644 index 0000000..1455121 --- /dev/null +++ b/packages/api/package.json @@ -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" + } +} diff --git a/packages/api/src/context.ts b/packages/api/src/context.ts new file mode 100644 index 0000000..899a741 --- /dev/null +++ b/packages/api/src/context.ts @@ -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 { + 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 }), + }; +} diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts new file mode 100644 index 0000000..9e70767 --- /dev/null +++ b/packages/api/src/index.ts @@ -0,0 +1,3 @@ +export { appRouter, type AppRouter } from './routers/_app'; +export { createTRPCContext, type Context, type SessionUser } from './context'; +export { createCallerFactory } from './trpc'; diff --git a/packages/api/src/logger.ts b/packages/api/src/logger.ts new file mode 100644 index 0000000..2a895ef --- /dev/null +++ b/packages/api/src/logger.ts @@ -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, +}); diff --git a/packages/api/src/routers/_app.ts b/packages/api/src/routers/_app.ts new file mode 100644 index 0000000..5626270 --- /dev/null +++ b/packages/api/src/routers/_app.ts @@ -0,0 +1,8 @@ +import { router } from '../trpc'; +import { pingRouter } from './ping'; + +export const appRouter = router({ + ping: pingRouter, +}); + +export type AppRouter = typeof appRouter; diff --git a/packages/api/src/routers/ping.ts b/packages/api/src/routers/ping.ts new file mode 100644 index 0000000..a2a5387 --- /dev/null +++ b/packages/api/src/routers/ping.ts @@ -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(), + }; + }), +}); diff --git a/packages/api/src/trpc.ts b/packages/api/src/trpc.ts new file mode 100644 index 0000000..1d78827 --- /dev/null +++ b/packages/api/src/trpc.ts @@ -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().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, + }, + }); +}); diff --git a/packages/api/tsconfig.json b/packages/api/tsconfig.json new file mode 100644 index 0000000..4675098 --- /dev/null +++ b/packages/api/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "@repo/config/tsconfig/base.json", + "compilerOptions": { + "noEmit": true, + "module": "ESNext", + "moduleResolution": "Bundler" + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/config/eslint/base.js b/packages/config/eslint/base.js new file mode 100644 index 0000000..a94b2cd --- /dev/null +++ b/packages/config/eslint/base.js @@ -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/**'], + }, +]; diff --git a/packages/config/eslint/nextjs.js b/packages/config/eslint/nextjs.js new file mode 100644 index 0000000..14835a3 --- /dev/null +++ b/packages/config/eslint/nextjs.js @@ -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, + }, + }, +]; diff --git a/packages/config/package.json b/packages/config/package.json new file mode 100644 index 0000000..31ba570 --- /dev/null +++ b/packages/config/package.json @@ -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" + } +} diff --git a/packages/config/tailwind/preset.cjs b/packages/config/tailwind/preset.cjs new file mode 100644 index 0000000..1c2d36c --- /dev/null +++ b/packages/config/tailwind/preset.cjs @@ -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], +}; diff --git a/packages/config/tsconfig/base.json b/packages/config/tsconfig/base.json new file mode 100644 index 0000000..e0805bc --- /dev/null +++ b/packages/config/tsconfig/base.json @@ -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"] +} diff --git a/packages/config/tsconfig/library.json b/packages/config/tsconfig/library.json new file mode 100644 index 0000000..d6011dc --- /dev/null +++ b/packages/config/tsconfig/library.json @@ -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"] +} diff --git a/packages/config/tsconfig/nextjs.json b/packages/config/tsconfig/nextjs.json new file mode 100644 index 0000000..6234e3e --- /dev/null +++ b/packages/config/tsconfig/nextjs.json @@ -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"] +} diff --git a/packages/db/package.json b/packages/db/package.json new file mode 100644 index 0000000..4b46c06 --- /dev/null +++ b/packages/db/package.json @@ -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" + } +} diff --git a/packages/db/prisma.config.ts b/packages/db/prisma.config.ts new file mode 100644 index 0000000..10e6d5f --- /dev/null +++ b/packages/db/prisma.config.ts @@ -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', + }, +}); diff --git a/packages/db/prisma/migrations/20260516101022_init/migration.sql b/packages/db/prisma/migrations/20260516101022_init/migration.sql new file mode 100644 index 0000000..53f4872 --- /dev/null +++ b/packages/db/prisma/migrations/20260516101022_init/migration.sql @@ -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; diff --git a/packages/db/prisma/migrations/migration_lock.toml b/packages/db/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..044d57c --- /dev/null +++ b/packages/db/prisma/migrations/migration_lock.toml @@ -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" diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma new file mode 100644 index 0000000..8a7ef60 --- /dev/null +++ b/packages/db/prisma/schema.prisma @@ -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]) +} diff --git a/packages/db/prisma/seed.ts b/packages/db/prisma/seed.ts new file mode 100644 index 0000000..523dcf2 --- /dev/null +++ b/packages/db/prisma/seed.ts @@ -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(); + }); diff --git a/packages/db/src/client.ts b/packages/db/src/client.ts new file mode 100644 index 0000000..7a7ea92 --- /dev/null +++ b/packages/db/src/client.ts @@ -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; diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts new file mode 100644 index 0000000..47b84b0 --- /dev/null +++ b/packages/db/src/index.ts @@ -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'; diff --git a/packages/db/src/tenant-extension.ts b/packages/db/src/tenant-extension.ts new file mode 100644 index 0000000..e1a4068 --- /dev/null +++ b/packages/db/src/tenant-extension.ts @@ -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; diff --git a/packages/db/tsconfig.json b/packages/db/tsconfig.json new file mode 100644 index 0000000..e204062 --- /dev/null +++ b/packages/db/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "@repo/config/tsconfig/base.json", + "compilerOptions": { + "noEmit": true, + "module": "ESNext", + "moduleResolution": "Bundler" + }, + "include": ["src/**/*.ts", "prisma/**/*.ts"] +} diff --git a/packages/domain/package.json b/packages/domain/package.json new file mode 100644 index 0000000..1ba2b59 --- /dev/null +++ b/packages/domain/package.json @@ -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" + } +} diff --git a/packages/domain/src/index.ts b/packages/domain/src/index.ts new file mode 100644 index 0000000..1098afd --- /dev/null +++ b/packages/domain/src/index.ts @@ -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 {}; diff --git a/packages/domain/tsconfig.json b/packages/domain/tsconfig.json new file mode 100644 index 0000000..4675098 --- /dev/null +++ b/packages/domain/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "@repo/config/tsconfig/base.json", + "compilerOptions": { + "noEmit": true, + "module": "ESNext", + "moduleResolution": "Bundler" + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/ui/package.json b/packages/ui/package.json new file mode 100644 index 0000000..fe3bfb5 --- /dev/null +++ b/packages/ui/package.json @@ -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" + } +} diff --git a/packages/ui/src/components/alert.tsx b/packages/ui/src/components/alert.tsx new file mode 100644 index 0000000..14ccd64 --- /dev/null +++ b/packages/ui/src/components/alert.tsx @@ -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 & VariantProps +>(({ className, variant, ...props }, ref) => ( +
+)); +Alert.displayName = 'Alert'; + +export const AlertTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +AlertTitle.displayName = 'AlertTitle'; + +export const AlertDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +AlertDescription.displayName = 'AlertDescription'; diff --git a/packages/ui/src/components/button.tsx b/packages/ui/src/components/button.tsx new file mode 100644 index 0000000..b66d5fa --- /dev/null +++ b/packages/ui/src/components/button.tsx @@ -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, + VariantProps {} + +export const Button = React.forwardRef( + ({ className, variant, size, ...props }, ref) => ( +