first project commit
This commit is contained in:
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 }),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { appRouter, type AppRouter } from './routers/_app';
|
||||
export { createTRPCContext, type Context, type SessionUser } from './context';
|
||||
export { createCallerFactory } from './trpc';
|
||||
@@ -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,
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
import { router } from '../trpc';
|
||||
import { pingRouter } from './ping';
|
||||
|
||||
export const appRouter = router({
|
||||
ping: pingRouter,
|
||||
});
|
||||
|
||||
export type AppRouter = typeof appRouter;
|
||||
@@ -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(),
|
||||
};
|
||||
}),
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "@repo/config/tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler"
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
@@ -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/**'],
|
||||
},
|
||||
];
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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],
|
||||
};
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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"
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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';
|
||||
@@ -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>;
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "@repo/config/tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler"
|
||||
},
|
||||
"include": ["src/**/*.ts", "prisma/**/*.ts"]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 {};
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "@repo/config/tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler"
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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 };
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user