diff --git a/.github/.copilot-instructions.md b/.github/.copilot-instructions.md new file mode 100644 index 0000000..a8a2f30 --- /dev/null +++ b/.github/.copilot-instructions.md @@ -0,0 +1,61 @@ +## Architecture & Priorities + +- This is a PoC: prioritize developer speed and clarity over abstraction. +- Treat all backends as APIs; do not couple services together. +- The AI service is stateless and must not call application APIs directly. + +## Monorepo Structure + +- This is a TurboRepo monorepo with pnpm workspaces +- Apps live in `apps/` (currently only tanstack-start) +- Shared packages live in `packages/` +- External services live in `services/` +- Use `turbo.json` for task definitions and caching + +## Environment & Configuration + +- Use Zod for environment validation in TypeScript packages +- Follow the pattern in `packages/auth/env.ts` for type-safe env vars +- Never commit sensitive values; use `example.env` as a template +- Prefix AI service env vars with `AI_` to avoid conflicts + +## Authentication + +- Use [WorkOS](https://workos.com/docs/llms.txt) for authentication and organization context. +- Do not invent custom auth flows. +- User identity and org context must be passed explicitly to services. + +## Frontend & UI + +- All reusable UI components must live in the `packages/ui` package. +- Use Tailwind CSS for styling. +- Prefer shadcn/ui components where applicable. +- Do not create ad-hoc UI components in app folders. + +## Forms & State + +- Use `@tanstack/react-form` for all forms. +- Use `@tanstack/react-query` for data fetching, caching, and mutations. +- Use `@tanstack/react-router` for routing. + +## APIs & Data Fetching + +- Use tRPC for type-safe application API calls (user data, metrics, app state). +- Use OpenAPI Generator to generate a typed client for the FastAPI AI service. +- Do not mix tRPC and OpenAPI clients. +- Do not have the AI service call the application API. + +## Database & Validation + +- Use Drizzle ORM for database access. +- Use Zod for validation. +- Use `drizzle-zod` for deriving Zod schemas from Drizzle models. +- Do not duplicate schema definitions manually. +- Place non-database Zod schemas in the `packages/validators` package. + +## AI Service Integration + +- The AI service receives all required inputs (user context, metrics, documents). +- The AI service must not fetch user or metrics data on its own. +- Inputs should be explicit, minimal, and versionable. +- AI service shouldn't have a seperate auth flow it should instead use the AI_SERVICE_KEY env var to authenticate requests from the main app which will have that variable sent using a secure header. diff --git a/.github/DISCUSSION_TEMPLATE/ideas.yml b/.github/DISCUSSION_TEMPLATE/ideas.yml new file mode 100644 index 0000000..c15eba2 --- /dev/null +++ b/.github/DISCUSSION_TEMPLATE/ideas.yml @@ -0,0 +1,21 @@ +body: + - type: markdown + attributes: + value: | + Thank you for taking the time to file a feature request. Please fill out this form as completely as possible. + - type: textarea + attributes: + label: Describe the feature you'd like to request + description: Please describe the feature as clear and concise as possible. Remember to add context as to why you believe this feature is needed. + validations: + required: true + - type: textarea + attributes: + label: Describe the solution you'd like to see + description: Please describe the solution you would like to see. Adding example usage is a good way to provide context. + validations: + required: true + - type: textarea + attributes: + label: Additional information + description: Add any other information related to the feature here. If your feature request is related to any issues or discussions, link them here. diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..54199a8 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,37 @@ +name: 🐞 Bug Report +description: Create a bug report to help us improve +title: "bug: " +labels: ["🐞❔ unconfirmed bug"] +body: + - type: textarea + attributes: + label: Provide environment information + description: | + Run this command in your project root and paste the results in a code block: + ```bash + npx envinfo --system --binaries + ``` + validations: + required: true + - type: textarea + attributes: + label: Describe the bug + description: A clear and concise description of the bug, as well as what you expected to happen when encountering it. + validations: + required: true + - type: input + attributes: + label: Link to reproduction + description: Please provide a link to a reproduction of the bug. Issues without a reproduction repo may be ignored. + validations: + required: true + - type: textarea + attributes: + label: To reproduce + description: Describe how to reproduce your bug. Steps, code snippets, reproduction repos etc. + validations: + required: true + - type: textarea + attributes: + label: Additional information + description: Add any other information related to the bug here, screenshots if applicable. diff --git a/.github/renovate.json b/.github/renovate.json new file mode 100644 index 0000000..28ff1a9 --- /dev/null +++ b/.github/renovate.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": ["config:base"], + "packageRules": [ + { + "matchPackagePatterns": ["^@governance/"], + "enabled": false + } + ], + "updateInternalDeps": true, + "rangeStrategy": "bump", + "automerge": true, + "npm": { + "fileMatch": ["(^|/)package\\.json$", "(^|/)package\\.json\\.hbs$"] + } +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..7d83d31 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,57 @@ +name: CI + +on: + pull_request: + branches: ["*"] + push: + branches: ["main"] + merge_group: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + +# You can leverage Vercel Remote Caching with Turbo to speed up your builds +# @link https://turborepo.com/docs/core-concepts/remote-caching#remote-caching-on-vercel-builds +env: + FORCE_COLOR: 3 + TURBO_TEAM: ${{ vars.TURBO_TEAM }} + TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - name: Setup + uses: ./tooling/github/setup + + - name: Copy env + shell: bash + run: cp .env.example .env + + - name: Lint + run: pnpm lint && pnpm lint:ws + + format: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - name: Setup + uses: ./tooling/github/setup + + - name: Format + run: pnpm format + + typecheck: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - name: Setup + uses: ./tooling/github/setup + + - name: Typecheck + run: pnpm typecheck diff --git a/.gitignore b/.gitignore index e540aed..9f749ad 100644 --- a/.gitignore +++ b/.gitignore @@ -1,24 +1,25 @@ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. # dependencies -node_modules/ -.pnp/ -.pnp.* -.yarn/* -!.yarn/patches -!.yarn/plugins -!.yarn/releases -!.yarn/versions +node_modules +.pnp +.pnp.js # testing -coverage/ +coverage # next.js .next/ out/ +next-env.d.ts + +# nitro +.nitro/ +.output/ + # production -build/ +build # misc .DS_Store @@ -30,16 +31,22 @@ yarn-debug.log* yarn-error.log* .pnpm-debug.log* -# env files (can opt-in for committing if needed) -.env* +# local env files +.env +.env*.local # vercel .vercel # typescript -*.tsbuildinfo -next-env.d.ts +dist/ +.cache + +# turbo +.turbo +# tanstack +.tanstack # ========= python ========= # Python-generated files diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..aa50a62 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +22.21.0 diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..fd682ae --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,8 @@ +{ + "recommendations": [ + "dbaeumer.vscode-eslint", + "esbenp.prettier-vscode", + "yoavbls.pretty-ts-errors", + "bradlc.vscode-tailwindcss" + ] +} diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..04f7850 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,17 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Next.js", + "type": "node-terminal", + "request": "launch", + "command": "pnpm dev", + "cwd": "${workspaceFolder}/apps/nextjs", + "skipFiles": ["/**"], + "sourceMaps": true, + "sourceMapPathOverrides": { + "/turbopack/[project]/*": "${webRoot}/*" //https://github.com/vercel/next.js/issues/62008 + } + } + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..44a73ec --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,7 @@ +{ + "eslint.workingDirectories": [ + { + "mode": "auto" + } + ] +} diff --git a/apps/tanstack-start/.prettierignore b/apps/tanstack-start/.prettierignore new file mode 100644 index 0000000..2a0e6b7 --- /dev/null +++ b/apps/tanstack-start/.prettierignore @@ -0,0 +1 @@ +routeTree.gen.ts \ No newline at end of file diff --git a/apps/tanstack-start/eslint.config.ts b/apps/tanstack-start/eslint.config.ts new file mode 100644 index 0000000..629f0e0 --- /dev/null +++ b/apps/tanstack-start/eslint.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from "eslint/config"; + +import { baseConfig, restrictEnvAccess } from "@governance/eslint-config/base"; +import { reactConfig } from "@governance/eslint-config/react"; + +export default defineConfig( + { + ignores: [".nitro/**", ".output/**", ".tanstack/**"], + }, + baseConfig, + reactConfig, + restrictEnvAccess, +); diff --git a/apps/tanstack-start/package.json b/apps/tanstack-start/package.json new file mode 100644 index 0000000..4f8ceb5 --- /dev/null +++ b/apps/tanstack-start/package.json @@ -0,0 +1,57 @@ +{ + "name": "@governance/tanstack-start", + "private": true, + "sideEffects": false, + "type": "module", + "scripts": { + "dev": "pnpm with-env vite dev", + "build": "vite build", + "start": "vite start", + "format": "prettier --check . --ignore-path ../../.gitignore --ignore-path .prettierignore", + "lint": "eslint --flag unstable_native_nodejs_ts_config", + "typecheck": "tsc --noEmit", + "with-env": "dotenv -e ../../.env --" + }, + "dependencies": { + "@fontsource-variable/geist": "^5.2.8", + "@fontsource-variable/geist-mono": "^5.2.7", + "@governance/api": "workspace:*", + "@governance/auth": "workspace:*", + "@governance/db": "workspace:*", + "@governance/ui": "workspace:*", + "@t3-oss/env-core": "^0.13.8", + "@tanstack/react-form": "catalog:", + "@tanstack/react-query": "catalog:", + "@tanstack/react-router": "^1.135.2", + "@tanstack/react-router-devtools": "^1.135.2", + "@tanstack/react-router-ssr-query": "^1.135.2", + "@tanstack/react-start": "^1.135.2", + "@trpc/client": "catalog:", + "@trpc/server": "catalog:", + "@trpc/tanstack-react-query": "catalog:", + "better-auth": "catalog:", + "nitro": "3.0.1-alpha.1", + "react": "catalog:react19", + "react-dom": "catalog:react19", + "superjson": "2.2.3", + "zod": "catalog:" + }, + "devDependencies": { + "@governance/eslint-config": "workspace:*", + "@governance/prettier-config": "workspace:*", + "@governance/tailwind-config": "workspace:*", + "@governance/tsconfig": "workspace:*", + "@tailwindcss/vite": "catalog:", + "@types/node": "catalog:", + "@types/react": "catalog:react19", + "@types/react-dom": "catalog:react19", + "@vitejs/plugin-react": "catalog:", + "eslint": "catalog:", + "prettier": "catalog:", + "tailwindcss": "catalog:", + "typescript": "catalog:", + "vite": "catalog:", + "vite-tsconfig-paths": "^5.1.4" + }, + "prettier": "@governance/prettier-config" +} diff --git a/apps/tanstack-start/public/favicon.ico b/apps/tanstack-start/public/favicon.ico new file mode 100644 index 0000000..f0058b4 Binary files /dev/null and b/apps/tanstack-start/public/favicon.ico differ diff --git a/apps/tanstack-start/src/auth/client.ts b/apps/tanstack-start/src/auth/client.ts new file mode 100644 index 0000000..f1012dd --- /dev/null +++ b/apps/tanstack-start/src/auth/client.ts @@ -0,0 +1,3 @@ +import { createAuthClient } from "better-auth/react"; + +export const authClient = createAuthClient(); diff --git a/apps/tanstack-start/src/auth/server.ts b/apps/tanstack-start/src/auth/server.ts new file mode 100644 index 0000000..5d59fe6 --- /dev/null +++ b/apps/tanstack-start/src/auth/server.ts @@ -0,0 +1,16 @@ +import { reactStartCookies } from "better-auth/react-start"; + +import { initAuth } from "@governance/auth"; + +import { env } from "~/env"; +import { getBaseUrl } from "~/lib/url"; + +export const auth = initAuth({ + baseUrl: getBaseUrl(), + productionUrl: `https://${env.VERCEL_PROJECT_PRODUCTION_URL ?? "turbo.t3.gg"}`, + secret: env.AUTH_SECRET, + discordClientId: env.AUTH_DISCORD_ID, + discordClientSecret: env.AUTH_DISCORD_SECRET, + + extraPlugins: [reactStartCookies()], +}); diff --git a/apps/tanstack-start/src/component/auth-showcase.tsx b/apps/tanstack-start/src/component/auth-showcase.tsx new file mode 100644 index 0000000..86a45c2 --- /dev/null +++ b/apps/tanstack-start/src/component/auth-showcase.tsx @@ -0,0 +1,48 @@ +import { useNavigate } from "@tanstack/react-router"; + +import { Button } from "@governance/ui/button"; + +import { authClient } from "~/auth/client"; + +export function AuthShowcase() { + const { data: session } = authClient.useSession(); + const navigate = useNavigate(); + + if (!session) { + return ( + + ); + } + + return ( +
+

+ Logged in as {session.user.name} +

+ + +
+ ); +} diff --git a/apps/tanstack-start/src/env.ts b/apps/tanstack-start/src/env.ts new file mode 100644 index 0000000..66f6f20 --- /dev/null +++ b/apps/tanstack-start/src/env.ts @@ -0,0 +1,36 @@ +import { createEnv } from "@t3-oss/env-core"; +import { vercel } from "@t3-oss/env-core/presets-zod"; +import { z } from "zod/v4"; + +import { authEnv } from "@governance/auth/env"; + +export const env = createEnv({ + clientPrefix: "VITE_", + extends: [authEnv(), vercel()], + shared: { + NODE_ENV: z + .enum(["development", "production", "test"]) + .default("development"), + }, + /** + * Specify your server-side environment variables schema here. + * This way you can ensure the app isn't built with invalid env vars. + */ + server: { + POSTGRES_URL: z.url(), + }, + + /** + * Specify your client-side environment variables schema here. + * For them to be exposed to the client, prefix them with `NEXT_PUBLIC_`. + */ + client: { + // NEXT_PUBLIC_CLIENTVAR: z.string(), + }, + /** + * Destructure all variables from `process.env` to make sure they aren't tree-shaken away. + */ + runtimeEnv: process.env, + skipValidation: + !!process.env.CI || process.env.npm_lifecycle_event === "lint", +}); diff --git a/apps/tanstack-start/src/lib/trpc.ts b/apps/tanstack-start/src/lib/trpc.ts new file mode 100644 index 0000000..70df5fb --- /dev/null +++ b/apps/tanstack-start/src/lib/trpc.ts @@ -0,0 +1,55 @@ +import { createIsomorphicFn } from "@tanstack/react-start"; +import { getRequestHeaders } from "@tanstack/react-start/server"; +import { + createTRPCClient, + httpBatchStreamLink, + loggerLink, + unstable_localLink, +} from "@trpc/client"; +import { createTRPCContext } from "@trpc/tanstack-react-query"; +import SuperJSON from "superjson"; + +import * as Api from "@governance/api"; + +import { auth } from "~/auth/server"; +import { env } from "~/env"; +import { getBaseUrl } from "~/lib/url"; + +export const makeTRPCClient = createIsomorphicFn() + .server(() => { + return createTRPCClient({ + links: [ + unstable_localLink({ + router: Api.appRouter, + transformer: SuperJSON, + createContext: () => { + const headers = new Headers(getRequestHeaders()); + headers.set("x-trpc-source", "tanstack-start-server"); + return Api.createTRPCContext({ auth, headers }); + }, + }), + ], + }); + }) + .client(() => { + return createTRPCClient({ + links: [ + loggerLink({ + enabled: (op) => + env.NODE_ENV === "development" || + (op.direction === "down" && op.result instanceof Error), + }), + httpBatchStreamLink({ + transformer: SuperJSON, + url: getBaseUrl() + "/api/trpc", + headers() { + const headers = new Headers(); + headers.set("x-trpc-source", "tanstack-start-client"); + return headers; + }, + }), + ], + }); + }); + +export const { useTRPC, TRPCProvider } = createTRPCContext(); diff --git a/apps/tanstack-start/src/lib/url.ts b/apps/tanstack-start/src/lib/url.ts new file mode 100644 index 0000000..8df496d --- /dev/null +++ b/apps/tanstack-start/src/lib/url.ts @@ -0,0 +1,16 @@ +import { env } from "~/env"; + +export function getBaseUrl() { + if (typeof window !== "undefined") { + return window.location.origin; + } + if (env.VERCEL_ENV === "production") { + return `https://${env.VERCEL_PROJECT_PRODUCTION_URL}`; + } + if (env.VERCEL_ENV === "preview") { + return `https://${env.VERCEL_URL}`; + } + + // eslint-disable-next-line no-restricted-properties + return `http://localhost:${process.env.PORT ?? 3001}`; +} diff --git a/apps/tanstack-start/src/routeTree.gen.ts b/apps/tanstack-start/src/routeTree.gen.ts new file mode 100644 index 0000000..14b20b8 --- /dev/null +++ b/apps/tanstack-start/src/routeTree.gen.ts @@ -0,0 +1,104 @@ +/* eslint-disable */ + +// @ts-nocheck + +// noinspection JSUnusedGlobalSymbols + +// This file was automatically generated by TanStack Router. +// You should NOT make any changes in this file as it will be overwritten. +// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. + +import { Route as rootRouteImport } from './routes/__root' +import { Route as IndexRouteImport } from './routes/index' +import { Route as ApiTrpcSplatRouteImport } from './routes/api/trpc.$' +import { Route as ApiAuthSplatRouteImport } from './routes/api/auth.$' + +const IndexRoute = IndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => rootRouteImport, +} as any) +const ApiTrpcSplatRoute = ApiTrpcSplatRouteImport.update({ + id: '/api/trpc/$', + path: '/api/trpc/$', + getParentRoute: () => rootRouteImport, +} as any) +const ApiAuthSplatRoute = ApiAuthSplatRouteImport.update({ + id: '/api/auth/$', + path: '/api/auth/$', + getParentRoute: () => rootRouteImport, +} as any) + +export interface FileRoutesByFullPath { + '/': typeof IndexRoute + '/api/auth/$': typeof ApiAuthSplatRoute + '/api/trpc/$': typeof ApiTrpcSplatRoute +} +export interface FileRoutesByTo { + '/': typeof IndexRoute + '/api/auth/$': typeof ApiAuthSplatRoute + '/api/trpc/$': typeof ApiTrpcSplatRoute +} +export interface FileRoutesById { + __root__: typeof rootRouteImport + '/': typeof IndexRoute + '/api/auth/$': typeof ApiAuthSplatRoute + '/api/trpc/$': typeof ApiTrpcSplatRoute +} +export interface FileRouteTypes { + fileRoutesByFullPath: FileRoutesByFullPath + fullPaths: '/' | '/api/auth/$' | '/api/trpc/$' + fileRoutesByTo: FileRoutesByTo + to: '/' | '/api/auth/$' | '/api/trpc/$' + id: '__root__' | '/' | '/api/auth/$' | '/api/trpc/$' + fileRoutesById: FileRoutesById +} +export interface RootRouteChildren { + IndexRoute: typeof IndexRoute + ApiAuthSplatRoute: typeof ApiAuthSplatRoute + ApiTrpcSplatRoute: typeof ApiTrpcSplatRoute +} + +declare module '@tanstack/react-router' { + interface FileRoutesByPath { + '/': { + id: '/' + path: '/' + fullPath: '/' + preLoaderRoute: typeof IndexRouteImport + parentRoute: typeof rootRouteImport + } + '/api/trpc/$': { + id: '/api/trpc/$' + path: '/api/trpc/$' + fullPath: '/api/trpc/$' + preLoaderRoute: typeof ApiTrpcSplatRouteImport + parentRoute: typeof rootRouteImport + } + '/api/auth/$': { + id: '/api/auth/$' + path: '/api/auth/$' + fullPath: '/api/auth/$' + preLoaderRoute: typeof ApiAuthSplatRouteImport + parentRoute: typeof rootRouteImport + } + } +} + +const rootRouteChildren: RootRouteChildren = { + IndexRoute: IndexRoute, + ApiAuthSplatRoute: ApiAuthSplatRoute, + ApiTrpcSplatRoute: ApiTrpcSplatRoute, +} +export const routeTree = rootRouteImport + ._addFileChildren(rootRouteChildren) + ._addFileTypes() + +import type { getRouter } from './router.tsx' +import type { createStart } from '@tanstack/react-start' +declare module '@tanstack/react-start' { + interface Register { + ssr: true + router: Awaited> + } +} diff --git a/apps/tanstack-start/src/router.tsx b/apps/tanstack-start/src/router.tsx new file mode 100644 index 0000000..869a0cc --- /dev/null +++ b/apps/tanstack-start/src/router.tsx @@ -0,0 +1,41 @@ +import { QueryClient } from "@tanstack/react-query"; +import { createRouter } from "@tanstack/react-router"; +import { setupRouterSsrQueryIntegration } from "@tanstack/react-router-ssr-query"; +import { createTRPCOptionsProxy } from "@trpc/tanstack-react-query"; +import SuperJSON from "superjson"; + +import { makeTRPCClient, TRPCProvider } from "~/lib/trpc"; +import { routeTree } from "./routeTree.gen"; + +export function getRouter() { + const queryClient = new QueryClient({ + defaultOptions: { + dehydrate: { serializeData: SuperJSON.serialize }, + hydrate: { deserializeData: SuperJSON.deserialize }, + }, + }); + const trpcClient = makeTRPCClient(); + const trpc = createTRPCOptionsProxy({ + client: trpcClient, + queryClient, + }); + + const router = createRouter({ + routeTree, + context: { queryClient, trpc }, + defaultPreload: "intent", + Wrap: (props) => ( + + ), + }); + setupRouterSsrQueryIntegration({ + router, + queryClient, + }); + + return router; +} diff --git a/apps/tanstack-start/src/routes/__root.tsx b/apps/tanstack-start/src/routes/__root.tsx new file mode 100644 index 0000000..66bb2a5 --- /dev/null +++ b/apps/tanstack-start/src/routes/__root.tsx @@ -0,0 +1,56 @@ +/// +import type { QueryClient } from "@tanstack/react-query"; +import type { TRPCOptionsProxy } from "@trpc/tanstack-react-query"; +import type * as React from "react"; +import { + createRootRouteWithContext, + HeadContent, + Outlet, + Scripts, +} from "@tanstack/react-router"; +import { TanStackRouterDevtools } from "@tanstack/react-router-devtools"; + +import type { AppRouter } from "@governance/api"; +import { ThemeProvider, ThemeToggle } from "@governance/ui/theme"; +import { Toaster } from "@governance/ui/toast"; + +import appCss from "~/styles.css?url"; + +export const Route = createRootRouteWithContext<{ + queryClient: QueryClient; + trpc: TRPCOptionsProxy; +}>()({ + head: () => ({ + links: [{ rel: "stylesheet", href: appCss }], + }), + component: RootComponent, +}); + +function RootComponent() { + return ( + + + + ); +} + +function RootDocument({ children }: { children: React.ReactNode }) { + return ( + + + + + + + {children} +
+ +
+ + + + + +
+ ); +} diff --git a/apps/tanstack-start/src/routes/api/auth.$.ts b/apps/tanstack-start/src/routes/api/auth.$.ts new file mode 100644 index 0000000..5c88113 --- /dev/null +++ b/apps/tanstack-start/src/routes/api/auth.$.ts @@ -0,0 +1,12 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { auth } from "~/auth/server"; + +export const Route = createFileRoute("/api/auth/$")({ + server: { + handlers: { + GET: ({ request }) => auth.handler(request), + POST: ({ request }) => auth.handler(request), + }, + }, +}); diff --git a/apps/tanstack-start/src/routes/api/trpc.$.ts b/apps/tanstack-start/src/routes/api/trpc.$.ts new file mode 100644 index 0000000..4abaa71 --- /dev/null +++ b/apps/tanstack-start/src/routes/api/trpc.$.ts @@ -0,0 +1,30 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { fetchRequestHandler } from "@trpc/server/adapters/fetch"; + +import { appRouter, createTRPCContext } from "@governance/api"; + +import { auth } from "~/auth/server"; + +const handler = (req: Request) => + fetchRequestHandler({ + endpoint: "/api/trpc", + router: appRouter, + req, + createContext: () => + createTRPCContext({ + auth: auth, + headers: req.headers, + }), + onError({ error, path }) { + console.error(`>>> tRPC Error on '${path}'`, error); + }, + }); + +export const Route = createFileRoute("/api/trpc/$")({ + server: { + handlers: { + GET: ({ request }) => handler(request), + POST: ({ request }) => handler(request), + }, + }, +}); diff --git a/apps/tanstack-start/src/routes/index.tsx b/apps/tanstack-start/src/routes/index.tsx new file mode 100644 index 0000000..0daf8ec --- /dev/null +++ b/apps/tanstack-start/src/routes/index.tsx @@ -0,0 +1,245 @@ +import { Suspense } from "react"; +import { useForm } from "@tanstack/react-form"; +import { + useMutation, + useQueryClient, + useSuspenseQuery, +} from "@tanstack/react-query"; +import { createFileRoute } from "@tanstack/react-router"; + +import type { RouterOutputs } from "@governance/api"; +import { CreatePostSchema } from "@governance/db/schema"; +import { cn } from "@governance/ui"; +import { Button } from "@governance/ui/button"; +import { + Field, + FieldContent, + FieldError, + FieldGroup, + FieldLabel, +} from "@governance/ui/field"; +import { Input } from "@governance/ui/input"; +import { toast } from "@governance/ui/toast"; + +import { AuthShowcase } from "~/component/auth-showcase"; +import { useTRPC } from "~/lib/trpc"; + +export const Route = createFileRoute("/")({ + loader: ({ context }) => { + const { trpc, queryClient } = context; + void queryClient.prefetchQuery(trpc.post.all.queryOptions()); + }, + component: RouteComponent, +}); + +function RouteComponent() { + return ( +
+
+

+ Create T3 Turbo +

+ + + +
+ + + + +
+ } + > + + +
+ +
+ ); +} + +function CreatePostForm() { + const trpc = useTRPC(); + + const queryClient = useQueryClient(); + const createPost = useMutation( + trpc.post.create.mutationOptions({ + onSuccess: async () => { + form.reset(); + await queryClient.invalidateQueries(trpc.post.pathFilter()); + }, + onError: (err) => { + toast.error( + err.data?.code === "UNAUTHORIZED" + ? "You must be logged in to post" + : "Failed to create post", + ); + }, + }), + ); + + const form = useForm({ + defaultValues: { + content: "", + title: "", + }, + validators: { + onSubmit: CreatePostSchema, + }, + onSubmit: (data) => createPost.mutate(data.value), + }); + + return ( +
{ + event.preventDefault(); + void form.handleSubmit(); + }} + > + + { + const isInvalid = + field.state.meta.isTouched && !field.state.meta.isValid; + return ( + + + Bug Title + + field.handleChange(e.target.value)} + aria-invalid={isInvalid} + placeholder="Title" + /> + {isInvalid && } + + ); + }} + /> + { + const isInvalid = + field.state.meta.isTouched && !field.state.meta.isValid; + return ( + + + Content + + field.handleChange(e.target.value)} + aria-invalid={isInvalid} + placeholder="Content" + /> + {isInvalid && } + + ); + }} + /> + + +
+ ); +} + +function PostList() { + const trpc = useTRPC(); + const { data: posts } = useSuspenseQuery(trpc.post.all.queryOptions()); + + if (posts.length === 0) { + return ( +
+ + + + +
+

No posts yet

+
+
+ ); + } + + return ( +
+ {posts.map((p) => { + return ; + })} +
+ ); +} + +function PostCard(props: { post: RouterOutputs["post"]["all"][number] }) { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + const deletePost = useMutation( + trpc.post.delete.mutationOptions({ + onSuccess: async () => { + await queryClient.invalidateQueries(trpc.post.pathFilter()); + }, + onError: (err) => { + toast.error( + err.data?.code === "UNAUTHORIZED" + ? "You must be logged in to delete a post" + : "Failed to delete post", + ); + }, + }), + ); + + return ( +
+
+

{props.post.title}

+

{props.post.content}

+
+
+ +
+
+ ); +} + +function PostCardSkeleton(props: { pulse?: boolean }) { + const { pulse = true } = props; + return ( +
+
+

+   +

+

+   +

+
+
+ ); +} diff --git a/apps/tanstack-start/src/styles.css b/apps/tanstack-start/src/styles.css new file mode 100644 index 0000000..7b13524 --- /dev/null +++ b/apps/tanstack-start/src/styles.css @@ -0,0 +1,35 @@ +@import "tailwindcss"; +@import "@governance/tailwind-config/theme"; + +@import "@fontsource-variable/geist"; +@import "@fontsource-variable/geist-mono"; + +@source "../../../packages/ui/src/*.{ts,tsx}"; + +@custom-variant dark (&:where(.dark, .dark *)); +@custom-variant light (&:where(.light, .light *)); +@custom-variant auto (&:where(.auto, .auto *)); + +@utility container { + margin-inline: auto; + padding-inline: 2rem; + @media (width >= --theme(--breakpoint-sm)) { + max-width: none; + } + @media (width >= 1400px) { + max-width: 1400px; + } +} + +@layer base { + :root { + --font-geist-sans: "Geist Variable"; + --font-geist-mono: "Geist Mono Variable"; + } + * { + @apply border-border; + } + body { + letter-spacing: var(--tracking-normal); + } +} diff --git a/apps/tanstack-start/tsconfig.json b/apps/tanstack-start/tsconfig.json new file mode 100644 index 0000000..b2a66b4 --- /dev/null +++ b/apps/tanstack-start/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "@governance/tsconfig/base.json", + "compilerOptions": { + "lib": ["ES2022", "dom", "dom.iterable"], + "jsx": "preserve", + "paths": { + "~/*": ["./src/*"] + } + }, + "include": ["**/*.ts", "**/*.tsx"], + "exclude": ["node_modules"] +} diff --git a/apps/tanstack-start/turbo.json b/apps/tanstack-start/turbo.json new file mode 100644 index 0000000..4c3f71f --- /dev/null +++ b/apps/tanstack-start/turbo.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://turborepo.com/schema.json", + "extends": ["//"], + "tasks": { + "build": { + "dependsOn": ["^build"], + "outputs": [".nitro/**", ".output/**", ".tanstack/**"] + }, + "dev": { + "persistent": true + } + } +} diff --git a/apps/tanstack-start/vite.config.ts b/apps/tanstack-start/vite.config.ts new file mode 100644 index 0000000..1208679 --- /dev/null +++ b/apps/tanstack-start/vite.config.ts @@ -0,0 +1,21 @@ +import tailwindcss from "@tailwindcss/vite"; +import { tanstackStart } from "@tanstack/react-start/plugin/vite"; +import viteReact from "@vitejs/plugin-react"; +import { nitro } from "nitro/vite"; +import { defineConfig } from "vite"; +import tsConfigPaths from "vite-tsconfig-paths"; + +export default defineConfig({ + server: { + port: 3001, + }, + plugins: [ + tsConfigPaths({ + projects: ["./tsconfig.json"], + }), + nitro(), + tanstackStart(), + viteReact(), + tailwindcss(), + ], +}); diff --git a/apps/web/eslint.config.mjs b/apps/web/eslint.config.mjs deleted file mode 100644 index 05e726d..0000000 --- a/apps/web/eslint.config.mjs +++ /dev/null @@ -1,18 +0,0 @@ -import { defineConfig, globalIgnores } from "eslint/config"; -import nextVitals from "eslint-config-next/core-web-vitals"; -import nextTs from "eslint-config-next/typescript"; - -const eslintConfig = defineConfig([ - ...nextVitals, - ...nextTs, - // Override default ignores of eslint-config-next. - globalIgnores([ - // Default ignores of eslint-config-next: - ".next/**", - "out/**", - "build/**", - "next-env.d.ts", - ]), -]); - -export default eslintConfig; diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts deleted file mode 100644 index 30d7f8b..0000000 --- a/apps/web/next.config.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { NextConfig } from "next"; - -const nextConfig: NextConfig = { - /* config options here */ - reactCompiler: true, - output: "standalone", -}; - -export default nextConfig; diff --git a/apps/web/package.json b/apps/web/package.json deleted file mode 100644 index 52dd8c1..0000000 --- a/apps/web/package.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "name": "@governance/web", - "version": "0.1.0", - "private": true, - "scripts": { - "dev": "next dev", - "build": "next build", - "start": "next start", - "lint": "eslint" - }, - "dependencies": { - "next": "16.1.3", - "react": "19.2.3", - "react-dom": "19.2.3" - }, - "devDependencies": { - "@tailwindcss/postcss": "^4", - "@types/node": "^20", - "@types/react": "^19", - "@types/react-dom": "^19", - "babel-plugin-react-compiler": "1.0.0", - "eslint": "^9", - "eslint-config-next": "16.1.3", - "tailwindcss": "^4", - "typescript": "^5" - } -} diff --git a/apps/web/public/file.svg b/apps/web/public/file.svg deleted file mode 100644 index 004145c..0000000 --- a/apps/web/public/file.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/apps/web/public/globe.svg b/apps/web/public/globe.svg deleted file mode 100644 index 567f17b..0000000 --- a/apps/web/public/globe.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/apps/web/public/next.svg b/apps/web/public/next.svg deleted file mode 100644 index 5174b28..0000000 --- a/apps/web/public/next.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/apps/web/public/vercel.svg b/apps/web/public/vercel.svg deleted file mode 100644 index 7705396..0000000 --- a/apps/web/public/vercel.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/apps/web/public/window.svg b/apps/web/public/window.svg deleted file mode 100644 index b2b2a44..0000000 --- a/apps/web/public/window.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/apps/web/src/app/favicon.ico b/apps/web/src/app/favicon.ico deleted file mode 100644 index 718d6fe..0000000 Binary files a/apps/web/src/app/favicon.ico and /dev/null differ diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css deleted file mode 100644 index a2dc41e..0000000 --- a/apps/web/src/app/globals.css +++ /dev/null @@ -1,26 +0,0 @@ -@import "tailwindcss"; - -:root { - --background: #ffffff; - --foreground: #171717; -} - -@theme inline { - --color-background: var(--background); - --color-foreground: var(--foreground); - --font-sans: var(--font-geist-sans); - --font-mono: var(--font-geist-mono); -} - -@media (prefers-color-scheme: dark) { - :root { - --background: #0a0a0a; - --foreground: #ededed; - } -} - -body { - background: var(--background); - color: var(--foreground); - font-family: Arial, Helvetica, sans-serif; -} diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx deleted file mode 100644 index f7fa87e..0000000 --- a/apps/web/src/app/layout.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import type { Metadata } from "next"; -import { Geist, Geist_Mono } from "next/font/google"; -import "./globals.css"; - -const geistSans = Geist({ - variable: "--font-geist-sans", - subsets: ["latin"], -}); - -const geistMono = Geist_Mono({ - variable: "--font-geist-mono", - subsets: ["latin"], -}); - -export const metadata: Metadata = { - title: "Create Next App", - description: "Generated by create next app", -}; - -export default function RootLayout({ - children, -}: Readonly<{ - children: React.ReactNode; -}>) { - return ( - - - {children} - - - ); -} diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx deleted file mode 100644 index 295f8fd..0000000 --- a/apps/web/src/app/page.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import Image from "next/image"; - -export default function Home() { - return ( -
-
- Next.js logo -
-

- To get started, edit the page.tsx file. -

-

- Looking for a starting point or more instructions? Head over to{" "} - - Templates - {" "} - or the{" "} - - Learning - {" "} - center. -

-
- -
-
- ); -} diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json deleted file mode 100644 index cf9c65d..0000000 --- a/apps/web/tsconfig.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2017", - "lib": ["dom", "dom.iterable", "esnext"], - "allowJs": true, - "skipLibCheck": true, - "strict": true, - "noEmit": true, - "esModuleInterop": true, - "module": "esnext", - "moduleResolution": "bundler", - "resolveJsonModule": true, - "isolatedModules": true, - "jsx": "react-jsx", - "incremental": true, - "plugins": [ - { - "name": "next" - } - ], - "paths": { - "@/*": ["./src/*"] - } - }, - "include": [ - "next-env.d.ts", - "**/*.ts", - "**/*.tsx", - ".next/types/**/*.ts", - ".next/dev/types/**/*.ts", - "**/*.mts" - ], - "exclude": ["node_modules"] -} diff --git a/example.env b/example.env index ed7f45e..c381413 100644 --- a/example.env +++ b/example.env @@ -5,6 +5,8 @@ OPENAI_API_KEY=your_openai_api_key_here QDRANT_URL=http://vector_db:6333 QDRANT_API_KEY= +AI_SERVICE_KEY="super-secret-internal-key" + # Database Configuration DATABASE_URL=postgresql://user:password@application_db:5432/app_db @@ -13,7 +15,10 @@ DATABASE_URL=postgresql://user:password@application_db:5432/app_db NEXT_PUBLIC_API_URL=http://localhost:5000 NEXTAUTH_SECRET=your_nextauth_secret_here NEXTAUTH_URL=http://localhost:5001 +AUTH_SECRET='supersecret' +AI_SERVICE_URL=http://localhost:5000 +AI_SERVICE_KEY="super-secret-internal-key" # Development settings NODE_ENV=development -PYTHONPATH=/app/src \ No newline at end of file +PYTHONPATH=/app/src diff --git a/package.json b/package.json index 1f7332d..6d05aa4 100644 --- a/package.json +++ b/package.json @@ -1,9 +1,35 @@ { "name": "governance-agent", "private": true, + "engines": { + "node": "^22.21.0", + "pnpm": "^10.19.0" + }, + "packageManager": "pnpm@10.19.0", "scripts": { - "dev": "pnpm --filter web dev", - "build": "pnpm -r build", - "lint": "pnpm -r lint" - } + "build": "turbo run build", + "clean": "git clean -xdf node_modules", + "clean:workspaces": "turbo run clean", + "auth:generate": "pnpm -F @governance/auth generate", + "db:push": "turbo -F @governance/db push", + "db:studio": "turbo -F @governance/db studio", + "dev": "turbo watch dev --continue", + "format": "turbo run format --continue -- --cache --cache-location .cache/.prettiercache", + "format:fix": "turbo run format --continue -- --write --cache --cache-location .cache/.prettiercache", + "lint": "turbo run lint --continue -- --cache --cache-location .cache/.eslintcache", + "lint:fix": "turbo run lint --continue -- --fix --cache --cache-location .cache/.eslintcache", + "lint:ws": "pnpm dlx sherif@latest", + "postinstall": "pnpm lint:ws", + "typecheck": "turbo run typecheck", + "ui-add": "turbo run ui-add" + }, + "devDependencies": { + "@governance/prettier-config": "workspace:*", + "@turbo/gen": "^2.5.8", + "dotenv-cli": "^10.0.0", + "prettier": "catalog:", + "turbo": "^2.5.8", + "typescript": "catalog:" + }, + "prettier": "@governance/prettier-config" } diff --git a/packages/api/eslint.config.ts b/packages/api/eslint.config.ts new file mode 100644 index 0000000..4ba2143 --- /dev/null +++ b/packages/api/eslint.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "eslint/config"; + +import { baseConfig } from "@governance/eslint-config/base"; + +export default defineConfig( + { + ignores: ["dist/**"], + }, + baseConfig, +); diff --git a/packages/api/package.json b/packages/api/package.json new file mode 100644 index 0000000..d6ad022 --- /dev/null +++ b/packages/api/package.json @@ -0,0 +1,37 @@ +{ + "name": "@governance/api", + "private": true, + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./src/index.ts" + } + }, + "license": "MIT", + "scripts": { + "build": "tsc", + "clean": "git clean -xdf .cache .turbo dist node_modules", + "dev": "tsc", + "format": "prettier --check . --ignore-path ../../.gitignore", + "lint": "eslint --flag unstable_native_nodejs_ts_config", + "typecheck": "tsc --noEmit --emitDeclarationOnly false" + }, + "dependencies": { + "@governance/auth": "workspace:*", + "@governance/db": "workspace:*", + "@governance/validators": "workspace:*", + "@trpc/server": "catalog:", + "superjson": "2.2.3", + "zod": "catalog:" + }, + "devDependencies": { + "@governance/eslint-config": "workspace:*", + "@governance/prettier-config": "workspace:*", + "@governance/tsconfig": "workspace:*", + "eslint": "catalog:", + "prettier": "catalog:", + "typescript": "catalog:" + }, + "prettier": "@governance/prettier-config" +} diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts new file mode 100644 index 0000000..090ff68 --- /dev/null +++ b/packages/api/src/index.ts @@ -0,0 +1,23 @@ +import type { inferRouterInputs, inferRouterOutputs } from "@trpc/server"; + +import type { AppRouter } from "./root"; + +/** + * Inference helpers for input types + * @example + * type PostByIdInput = RouterInputs['post']['byId'] + * ^? { id: number } + */ +type RouterInputs = inferRouterInputs; + +/** + * Inference helpers for output types + * @example + * type AllPostsOutput = RouterOutputs['post']['all'] + * ^? Post[] + */ +type RouterOutputs = inferRouterOutputs; + +export { type AppRouter, appRouter } from "./root"; +export { createTRPCContext } from "./trpc"; +export type { RouterInputs, RouterOutputs }; diff --git a/packages/api/src/root.ts b/packages/api/src/root.ts new file mode 100644 index 0000000..730251c --- /dev/null +++ b/packages/api/src/root.ts @@ -0,0 +1,11 @@ +import { authRouter } from "./router/auth"; +import { postRouter } from "./router/post"; +import { createTRPCRouter } from "./trpc"; + +export const appRouter = createTRPCRouter({ + auth: authRouter, + post: postRouter, +}); + +// export type definition of API +export type AppRouter = typeof appRouter; diff --git a/packages/api/src/router/auth.ts b/packages/api/src/router/auth.ts new file mode 100644 index 0000000..230c088 --- /dev/null +++ b/packages/api/src/router/auth.ts @@ -0,0 +1,12 @@ +import type { TRPCRouterRecord } from "@trpc/server"; + +import { protectedProcedure, publicProcedure } from "../trpc"; + +export const authRouter = { + getSession: publicProcedure.query(({ ctx }) => { + return ctx.session; + }), + getSecretMessage: protectedProcedure.query(() => { + return "you can see this secret message!"; + }), +} satisfies TRPCRouterRecord; diff --git a/packages/api/src/router/post.ts b/packages/api/src/router/post.ts new file mode 100644 index 0000000..1bbdacd --- /dev/null +++ b/packages/api/src/router/post.ts @@ -0,0 +1,34 @@ +import type { TRPCRouterRecord } from "@trpc/server"; +import { z } from "zod/v4"; + +import { desc, eq } from "@governance/db"; +import { CreatePostSchema, Post } from "@governance/db/schema"; + +import { protectedProcedure, publicProcedure } from "../trpc"; + +export const postRouter = { + all: publicProcedure.query(({ ctx }) => { + return ctx.db.query.Post.findMany({ + orderBy: desc(Post.id), + limit: 10, + }); + }), + + byId: publicProcedure + .input(z.object({ id: z.string() })) + .query(({ ctx, input }) => { + return ctx.db.query.Post.findFirst({ + where: eq(Post.id, input.id), + }); + }), + + create: protectedProcedure + .input(CreatePostSchema) + .mutation(({ ctx, input }) => { + return ctx.db.insert(Post).values(input); + }), + + delete: protectedProcedure.input(z.string()).mutation(({ ctx, input }) => { + return ctx.db.delete(Post).where(eq(Post.id, input)); + }), +} satisfies TRPCRouterRecord; diff --git a/packages/api/src/trpc.ts b/packages/api/src/trpc.ts new file mode 100644 index 0000000..dafc12c --- /dev/null +++ b/packages/api/src/trpc.ts @@ -0,0 +1,128 @@ +/** + * YOU PROBABLY DON'T NEED TO EDIT THIS FILE, UNLESS: + * 1. You want to modify request context (see Part 1) + * 2. You want to create a new middleware or type of procedure (see Part 3) + * + * tl;dr - this is where all the tRPC server stuff is created and plugged in. + * The pieces you will need to use are documented accordingly near the end + */ +import { initTRPC, TRPCError } from "@trpc/server"; +import superjson from "superjson"; +import { z, ZodError } from "zod/v4"; + +import type { Auth } from "@governance/auth"; +import { db } from "@governance/db/client"; + +/** + * 1. CONTEXT + * + * This section defines the "contexts" that are available in the backend API. + * + * These allow you to access things when processing a request, like the database, the session, etc. + * + * This helper generates the "internals" for a tRPC context. The API handler and RSC clients each + * wrap this and provides the required context. + * + * @see https://trpc.io/docs/server/context + */ + +export const createTRPCContext = async (opts: { + headers: Headers; + auth: Auth; +}) => { + const authApi = opts.auth.api; + const session = await authApi.getSession({ + headers: opts.headers, + }); + return { + authApi, + session, + db, + }; +}; +/** + * 2. INITIALIZATION + * + * This is where the trpc api is initialized, connecting the context and + * transformer + */ +const t = initTRPC.context().create({ + transformer: superjson, + errorFormatter: ({ shape, error }) => ({ + ...shape, + data: { + ...shape.data, + zodError: + error.cause instanceof ZodError + ? z.flattenError(error.cause as ZodError>) + : null, + }, + }), +}); + +/** + * 3. ROUTER & PROCEDURE (THE IMPORTANT BIT) + * + * These are the pieces you use to build your tRPC API. You should import these + * a lot in the /src/server/api/routers folder + */ + +/** + * This is how you create new routers and subrouters in your tRPC API + * @see https://trpc.io/docs/router + */ +export const createTRPCRouter = t.router; + +/** + * Middleware for timing procedure execution and adding an articifial delay in development. + * + * You can remove this if you don't like it, but it can help catch unwanted waterfalls by simulating + * network latency that would occur in production but not in local development. + */ +const timingMiddleware = t.middleware(async ({ next, path }) => { + const start = Date.now(); + + if (t._config.isDev) { + // artificial delay in dev 100-500ms + const waitMs = Math.floor(Math.random() * 400) + 100; + await new Promise((resolve) => setTimeout(resolve, waitMs)); + } + + const result = await next(); + + const end = Date.now(); + console.log(`[TRPC] ${path} took ${end - start}ms to execute`); + + return result; +}); + +/** + * Public (unauthed) procedure + * + * This is the base piece you use to build new queries and mutations on your + * tRPC API. It does not guarantee that a user querying is authorized, but you + * can still access user session data if they are logged in + */ +export const publicProcedure = t.procedure.use(timingMiddleware); + +/** + * Protected (authenticated) procedure + * + * If you want a query or mutation to ONLY be accessible to logged in users, use this. It verifies + * the session is valid and guarantees `ctx.session.user` is not null. + * + * @see https://trpc.io/docs/procedures + */ +export const protectedProcedure = t.procedure + .use(timingMiddleware) + .use(({ ctx, next }) => { + if (!ctx.session?.user) { + throw new TRPCError({ code: "UNAUTHORIZED" }); + } + return next({ + ctx: { + // infers the `session` as non-nullable + session: { ...ctx.session, user: ctx.session.user }, + }, + }); + }); diff --git a/packages/api/tsconfig.json b/packages/api/tsconfig.json new file mode 100644 index 0000000..7effb17 --- /dev/null +++ b/packages/api/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "@governance/tsconfig/compiled-package.json", + "include": ["src"], + "exclude": ["node_modules"] +} diff --git a/packages/auth/env.ts b/packages/auth/env.ts new file mode 100644 index 0000000..6b40d33 --- /dev/null +++ b/packages/auth/env.ts @@ -0,0 +1,17 @@ +import { createEnv } from "@t3-oss/env-core"; +import { z } from "zod/v4"; + +export function authEnv() { + return createEnv({ + server: { + AUTH_SECRET: + process.env.NODE_ENV === "production" + ? z.string().min(1) + : z.string().min(1).optional(), + NODE_ENV: z.enum(["development", "production"]).optional(), + }, + runtimeEnv: process.env, + skipValidation: + !!process.env.CI || process.env.npm_lifecycle_event === "lint", + }); +} diff --git a/packages/auth/eslint.config.ts b/packages/auth/eslint.config.ts new file mode 100644 index 0000000..82368a8 --- /dev/null +++ b/packages/auth/eslint.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from "eslint/config"; + +import { baseConfig, restrictEnvAccess } from "@governance/eslint-config/base"; + +export default defineConfig( + { + ignores: ["script/**"], + }, + baseConfig, + restrictEnvAccess, +); diff --git a/packages/auth/package.json b/packages/auth/package.json new file mode 100644 index 0000000..7b30360 --- /dev/null +++ b/packages/auth/package.json @@ -0,0 +1,35 @@ +{ + "name": "@governance/auth", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts", + "./middleware": "./src/middleware.ts", + "./client": "./src/client.ts", + "./env": "./env.ts" + }, + "license": "MIT", + "scripts": { + "clean": "git clean -xdf .cache .turbo dist node_modules", + "format": "prettier --check . --ignore-path ../../.gitignore", + "lint": "eslint --flag unstable_native_nodejs_ts_config", + "generate": "dotenv -e ../../.env -- pnpx @better-auth/cli generate --config script/auth-cli.ts --output ../db/src/auth-schema.ts", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@governance/db": "workspace:*", + "@t3-oss/env-core": "^0.13.8", + "better-auth": "catalog:", + "zod": "catalog:" + }, + "devDependencies": { + "@better-auth/cli": "catalog:", + "@governance/eslint-config": "workspace:*", + "@governance/prettier-config": "workspace:*", + "@governance/tsconfig": "workspace:*", + "eslint": "catalog:", + "prettier": "catalog:", + "typescript": "catalog:" + }, + "prettier": "@governance/prettier-config" +} diff --git a/packages/auth/script/auth-cli.ts b/packages/auth/script/auth-cli.ts new file mode 100644 index 0000000..fec9d34 --- /dev/null +++ b/packages/auth/script/auth-cli.ts @@ -0,0 +1,25 @@ +/** + * @fileoverview Better Auth CLI Configuration + * + * This file is used exclusively by the Better Auth CLI to generate database schemas. + * DO NOT USE THIS FILE DIRECTLY IN YOUR APPLICATION. + * + * This configuration is consumed by the CLI command: + * `pnpx @better-auth/cli generate --config script/auth-cli.ts --output ../db/src/auth-schema.ts` + * + * For actual authentication usage, import from "../src/index.ts" instead. + */ + +import { initAuth } from "../src/index"; + +/** + * CLI-only authentication configuration for schema generation. + * + * @warning This configuration is NOT intended for runtime use. + * @warning Use the main auth configuration from "../src/index.ts" for your application. + */ +export const auth = initAuth({ + baseUrl: "http://localhost:3000", + productionUrl: "http://localhost:3000", + secret: "secret", +}); diff --git a/packages/auth/src/index.ts b/packages/auth/src/index.ts new file mode 100644 index 0000000..f173692 --- /dev/null +++ b/packages/auth/src/index.ts @@ -0,0 +1,40 @@ +import type { BetterAuthOptions, BetterAuthPlugin } from "better-auth"; +import { db } from "@governance/db/client"; +import { betterAuth } from "better-auth"; +import { drizzleAdapter } from "better-auth/adapters/drizzle"; +import { oAuthProxy } from "better-auth/plugins"; + +export function initAuth< + TExtraPlugins extends BetterAuthPlugin[] = [], +>(options: { + baseUrl: string; + productionUrl: string; + secret: string | undefined; + extraPlugins?: TExtraPlugins; +}) { + const config = { + database: drizzleAdapter(db, { + provider: "pg", + }), + baseURL: options.baseUrl, + secret: options.secret, + plugins: [ + oAuthProxy({ + productionURL: options.productionUrl, + }), + + ...(options.extraPlugins ?? []), + ], + socialProviders: {}, + onAPIError: { + onError(error, ctx) { + console.error("BETTER AUTH API ERROR", error, ctx); + }, + }, + } satisfies BetterAuthOptions; + + return betterAuth(config); +} + +export type Auth = ReturnType; +export type Session = Auth["$Infer"]["Session"]; diff --git a/packages/auth/tsconfig.json b/packages/auth/tsconfig.json new file mode 100644 index 0000000..3592780 --- /dev/null +++ b/packages/auth/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "@governance/tsconfig/base.json", + "include": ["src", "*.ts"], + "exclude": ["node_modules"] +} diff --git a/packages/db/drizzle.config.ts b/packages/db/drizzle.config.ts new file mode 100644 index 0000000..5b18085 --- /dev/null +++ b/packages/db/drizzle.config.ts @@ -0,0 +1,14 @@ +import type { Config } from "drizzle-kit"; + +if (!process.env.POSTGRES_URL) { + throw new Error("Missing POSTGRES_URL"); +} + +const nonPoolingUrl = process.env.POSTGRES_URL.replace(":6543", ":5432"); + +export default { + schema: "./src/schema.ts", + dialect: "postgresql", + dbCredentials: { url: nonPoolingUrl }, + casing: "snake_case", +} satisfies Config; diff --git a/packages/db/eslint.config.ts b/packages/db/eslint.config.ts new file mode 100644 index 0000000..4ba2143 --- /dev/null +++ b/packages/db/eslint.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "eslint/config"; + +import { baseConfig } from "@governance/eslint-config/base"; + +export default defineConfig( + { + ignores: ["dist/**"], + }, + baseConfig, +); diff --git a/packages/db/package.json b/packages/db/package.json new file mode 100644 index 0000000..f3d4928 --- /dev/null +++ b/packages/db/package.json @@ -0,0 +1,47 @@ +{ + "name": "@governance/db", + "private": true, + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./src/index.ts" + }, + "./client": { + "types": "./dist/client.d.ts", + "default": "./src/client.ts" + }, + "./schema": { + "types": "./dist/schema.d.ts", + "default": "./src/schema.ts" + } + }, + "license": "MIT", + "scripts": { + "build": "tsc", + "clean": "git clean -xdf .cache .turbo dist node_modules", + "dev": "tsc", + "format": "prettier --check . --ignore-path ../../.gitignore", + "lint": "eslint --flag unstable_native_nodejs_ts_config", + "push": "pnpm with-env drizzle-kit push", + "studio": "pnpm with-env drizzle-kit studio", + "typecheck": "tsc --noEmit --emitDeclarationOnly false", + "with-env": "dotenv -e ../../.env --" + }, + "dependencies": { + "@vercel/postgres": "^0.10.0", + "drizzle-orm": "^0.44.7", + "drizzle-zod": "^0.8.3", + "zod": "catalog:" + }, + "devDependencies": { + "@governance/eslint-config": "workspace:*", + "@governance/prettier-config": "workspace:*", + "@governance/tsconfig": "workspace:*", + "drizzle-kit": "^0.31.5", + "eslint": "catalog:", + "prettier": "catalog:", + "typescript": "catalog:" + }, + "prettier": "@governance/prettier-config" +} diff --git a/packages/db/src/auth-schema.ts b/packages/db/src/auth-schema.ts new file mode 100644 index 0000000..0195bd9 --- /dev/null +++ b/packages/db/src/auth-schema.ts @@ -0,0 +1,53 @@ +import { pgTable } from "drizzle-orm/pg-core"; + +export const user = pgTable("user", (t) => ({ + id: t.text().primaryKey(), + name: t.text().notNull(), + email: t.text().notNull().unique(), + emailVerified: t.boolean().notNull(), + image: t.text(), + createdAt: t.timestamp().notNull(), + updatedAt: t.timestamp().notNull(), +})); + +export const session = pgTable("session", (t) => ({ + id: t.text().primaryKey(), + expiresAt: t.timestamp().notNull(), + token: t.text().notNull().unique(), + createdAt: t.timestamp().notNull(), + updatedAt: t.timestamp().notNull(), + ipAddress: t.text(), + userAgent: t.text(), + userId: t + .text() + .notNull() + .references(() => user.id, { onDelete: "cascade" }), +})); + +export const account = pgTable("account", (t) => ({ + id: t.text().primaryKey(), + accountId: t.text().notNull(), + providerId: t.text().notNull(), + userId: t + .text() + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + accessToken: t.text(), + refreshToken: t.text(), + idToken: t.text(), + accessTokenExpiresAt: t.timestamp(), + refreshTokenExpiresAt: t.timestamp(), + scope: t.text(), + password: t.text(), + createdAt: t.timestamp().notNull(), + updatedAt: t.timestamp().notNull(), +})); + +export const verification = pgTable("verification", (t) => ({ + id: t.text().primaryKey(), + identifier: t.text().notNull(), + value: t.text().notNull(), + expiresAt: t.timestamp().notNull(), + createdAt: t.timestamp(), + updatedAt: t.timestamp(), +})); diff --git a/packages/db/src/client.ts b/packages/db/src/client.ts new file mode 100644 index 0000000..e23181e --- /dev/null +++ b/packages/db/src/client.ts @@ -0,0 +1,10 @@ +import { sql } from "@vercel/postgres"; +import { drizzle } from "drizzle-orm/vercel-postgres"; + +import * as schema from "./schema"; + +export const db = drizzle({ + client: sql, + schema, + casing: "snake_case", +}); diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts new file mode 100644 index 0000000..f0585be --- /dev/null +++ b/packages/db/src/index.ts @@ -0,0 +1,2 @@ +export * from "drizzle-orm/sql"; +export { alias } from "drizzle-orm/pg-core"; diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts new file mode 100644 index 0000000..b519825 --- /dev/null +++ b/packages/db/src/schema.ts @@ -0,0 +1,25 @@ +import { sql } from "drizzle-orm"; +import { pgTable } from "drizzle-orm/pg-core"; +import { createInsertSchema } from "drizzle-zod"; +import { z } from "zod/v4"; + +export const Post = pgTable("post", (t) => ({ + id: t.uuid().notNull().primaryKey().defaultRandom(), + title: t.varchar({ length: 256 }).notNull(), + content: t.text().notNull(), + createdAt: t.timestamp().defaultNow().notNull(), + updatedAt: t + .timestamp({ mode: "date", withTimezone: true }) + .$onUpdateFn(() => sql`now()`), +})); + +export const CreatePostSchema = createInsertSchema(Post, { + title: z.string().max(256), + content: z.string().max(256), +}).omit({ + id: true, + createdAt: true, + updatedAt: true, +}); + +export * from "./auth-schema"; diff --git a/packages/db/tsconfig.json b/packages/db/tsconfig.json new file mode 100644 index 0000000..7effb17 --- /dev/null +++ b/packages/db/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "@governance/tsconfig/compiled-package.json", + "include": ["src"], + "exclude": ["node_modules"] +} diff --git a/packages/ui/components.json b/packages/ui/components.json new file mode 100644 index 0000000..72c5e4e --- /dev/null +++ b/packages/ui/components.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "", + "css": "unused.css", + "baseColor": "zinc", + "cssVariables": true + }, + "aliases": { + "utils": "@governance/ui", + "components": "src/", + "ui": "src/" + } +} diff --git a/packages/ui/eslint.config.ts b/packages/ui/eslint.config.ts new file mode 100644 index 0000000..9bf949f --- /dev/null +++ b/packages/ui/eslint.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from "eslint/config"; + +import { baseConfig } from "@governance/eslint-config/base"; +import { reactConfig } from "@governance/eslint-config/react"; + +export default defineConfig( + { + ignores: ["dist/**"], + }, + baseConfig, + reactConfig, +); diff --git a/packages/ui/package.json b/packages/ui/package.json new file mode 100644 index 0000000..fff10b0 --- /dev/null +++ b/packages/ui/package.json @@ -0,0 +1,47 @@ +{ + "name": "@governance/ui", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts", + "./button": "./src/button.tsx", + "./dropdown-menu": "./src/dropdown-menu.tsx", + "./field": "./src/field.tsx", + "./input": "./src/input.tsx", + "./label": "./src/label.tsx", + "./separator": "./src/separator.tsx", + "./theme": "./src/theme.tsx", + "./toast": "./src/toast.tsx" + }, + "license": "MIT", + "scripts": { + "clean": "git clean -xdf .cache .turbo dist node_modules", + "format": "prettier --check . --ignore-path ../../.gitignore", + "lint": "eslint --flag unstable_native_nodejs_ts_config", + "typecheck": "tsc --noEmit --emitDeclarationOnly false", + "ui-add": "pnpm dlx shadcn@latest add && prettier src --write --list-different" + }, + "dependencies": { + "@radix-ui/react-icons": "^1.3.2", + "class-variance-authority": "^0.7.1", + "radix-ui": "^1.4.3", + "sonner": "^2.0.7", + "tailwind-merge": "^3.3.1" + }, + "devDependencies": { + "@governance/eslint-config": "workspace:*", + "@governance/prettier-config": "workspace:*", + "@governance/tsconfig": "workspace:*", + "@types/react": "catalog:react19", + "eslint": "catalog:", + "prettier": "catalog:", + "react": "catalog:react19", + "typescript": "catalog:", + "zod": "catalog:" + }, + "peerDependencies": { + "react": "catalog:react19", + "zod": "catalog:" + }, + "prettier": "@governance/prettier-config" +} diff --git a/packages/ui/src/button.tsx b/packages/ui/src/button.tsx new file mode 100644 index 0000000..b7ccd66 --- /dev/null +++ b/packages/ui/src/button.tsx @@ -0,0 +1,57 @@ +import type { VariantProps } from "class-variance-authority"; +import { cva } from "class-variance-authority"; +import { Slot as SlotPrimitive } from "radix-ui"; + +import { cn } from "@governance/ui"; + +export const buttonVariants = cva( + "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + { + variants: { + variant: { + default: + "bg-primary text-primary-foreground hover:bg-primary/90 shadow-xs", + destructive: + "bg-destructive hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60 text-white shadow-xs", + outline: + "bg-background hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 border shadow-xs", + secondary: + "bg-secondary text-secondary-foreground hover:bg-secondary/80 shadow-xs", + ghost: + "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-9 px-4 py-2 has-[>svg]:px-3", + sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5", + lg: "h-10 rounded-md px-6 has-[>svg]:px-4", + icon: "size-9", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + }, +); + +export function Button({ + className, + variant, + size, + asChild = false, + ...props +}: React.ComponentProps<"button"> & + VariantProps & { + asChild?: boolean; + }) { + const Comp = asChild ? SlotPrimitive.Slot : "button"; + + return ( + + ); +} diff --git a/packages/ui/src/dropdown-menu.tsx b/packages/ui/src/dropdown-menu.tsx new file mode 100644 index 0000000..f3fb849 --- /dev/null +++ b/packages/ui/src/dropdown-menu.tsx @@ -0,0 +1,242 @@ +"use client"; + +import { + CheckIcon, + ChevronRightIcon, + DotFilledIcon, +} from "@radix-ui/react-icons"; +import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"; + +import { cn } from "@governance/ui"; + +export function DropdownMenu({ + ...props +}: React.ComponentProps) { + return ; +} + +export function DropdownMenuPortal({ + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export function DropdownMenuTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export function DropdownMenuContent({ + className, + sideOffset = 4, + ...props +}: React.ComponentProps) { + return ( + + + + ); +} + +export function DropdownMenuGroup({ + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export function DropdownMenuItem({ + className, + inset, + variant = "default", + ...props +}: React.ComponentProps & { + inset?: boolean; + variant?: "default" | "destructive"; +}) { + return ( + + ); +} + +export function DropdownMenuCheckboxItem({ + className, + children, + checked, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ); +} + +export function DropdownMenuRadioGroup({ + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export function DropdownMenuRadioItem({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ); +} + +export function DropdownMenuLabel({ + className, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean; +}) { + return ( + + ); +} + +export function DropdownMenuSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export function DropdownMenuShortcut({ + className, + ...props +}: React.ComponentProps<"span">) { + return ( + + ); +} + +export function DropdownMenuSub({ + ...props +}: React.ComponentProps) { + return ; +} + +export function DropdownMenuSubTrigger({ + className, + inset, + children, + ...props +}: React.ComponentProps & { + inset?: boolean; +}) { + return ( + + {children} + + + ); +} + +export function DropdownMenuSubContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} diff --git a/packages/ui/src/field.tsx b/packages/ui/src/field.tsx new file mode 100644 index 0000000..0f6b687 --- /dev/null +++ b/packages/ui/src/field.tsx @@ -0,0 +1,249 @@ +"use client"; + +import type { VariantProps } from "class-variance-authority"; +import { useMemo } from "react"; +import { cva } from "class-variance-authority"; + +import { cn } from "@governance/ui"; +import { Label } from "@governance/ui/label"; +import { Separator } from "@governance/ui/separator"; + +export function FieldSet({ + className, + ...props +}: React.ComponentProps<"fieldset">) { + return ( +
[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3", + className, + )} + {...props} + /> + ); +} + +export function FieldLegend({ + className, + variant = "legend", + ...props +}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) { + return ( + + ); +} + +export function FieldGroup({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
[data-slot=field-group]]:gap-4", + className, + )} + {...props} + /> + ); +} + +const fieldVariants = cva( + "group/field data-[invalid=true]:text-destructive flex w-full gap-3", + { + variants: { + orientation: { + vertical: ["flex-col [&>*]:w-full [&>.sr-only]:w-auto"], + horizontal: [ + "flex-row items-center", + "[&>[data-slot=field-label]]:flex-auto", + "has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px", + ], + responsive: [ + "flex-col @md/field-group:flex-row @md/field-group:items-center [&>*]:w-full @md/field-group:[&>*]:w-auto [&>.sr-only]:w-auto", + "@md/field-group:[&>[data-slot=field-label]]:flex-auto", + "@md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px", + ], + }, + }, + defaultVariants: { + orientation: "vertical", + }, + }, +); + +export function Field({ + className, + orientation = "vertical", + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
+ ); +} + +export function FieldContent({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ); +} + +export function FieldLabel({ + className, + ...props +}: React.ComponentProps) { + return ( +