From bd0cf2d70a8d6cfa6d19786346e215df56b1cd57 Mon Sep 17 00:00:00 2001 From: Oleg Proskurin Date: Fri, 3 Oct 2025 00:14:02 +0700 Subject: [PATCH] feat: add keys pages --- .mcp.json | 2 +- apps/landing/package.json | 3 +- apps/landing/src/app/admin/apikeys/page.tsx | 208 ++++++++++++++++++ apps/landing/src/app/admin/master/page.tsx | 153 +++++++++++++ apps/landing/src/app/layout.tsx | 60 ++++- apps/landing/src/app/page.tsx | 59 +---- .../src/components/admin/AdminButton.tsx | 36 +++ .../src/components/admin/AdminFormInput.tsx | 36 +++ .../src/components/admin/CopyButton.tsx | 36 +++ .../src/components/admin/KeyDisplay.tsx | 39 ++++ apps/landing/src/lib/actions/apiKeyActions.ts | 96 ++++++++ .../src/lib/actions/orgProjectActions.ts | 41 ++++ apps/landing/src/lib/db/client.ts | 11 + apps/landing/src/lib/db/queries/apiKeys.ts | 35 +++ .../src/lib/db/queries/organizations.ts | 29 +++ apps/landing/src/lib/db/queries/projects.ts | 35 +++ .../migrations/0000_gifted_sunfire.sql | 15 -- .../migrations/meta/0000_snapshot.json | 117 ---------- .../database/migrations/meta/_journal.json | 10 +- packages/database/src/schema/apiKeys.ts | 7 +- packages/database/src/schema/index.ts | 35 ++- packages/database/src/schema/organizations.ts | 16 ++ packages/database/src/schema/projects.ts | 20 ++ 23 files changed, 895 insertions(+), 204 deletions(-) create mode 100644 apps/landing/src/app/admin/apikeys/page.tsx create mode 100644 apps/landing/src/app/admin/master/page.tsx create mode 100644 apps/landing/src/components/admin/AdminButton.tsx create mode 100644 apps/landing/src/components/admin/AdminFormInput.tsx create mode 100644 apps/landing/src/components/admin/CopyButton.tsx create mode 100644 apps/landing/src/components/admin/KeyDisplay.tsx create mode 100644 apps/landing/src/lib/actions/apiKeyActions.ts create mode 100644 apps/landing/src/lib/actions/orgProjectActions.ts create mode 100644 apps/landing/src/lib/db/client.ts create mode 100644 apps/landing/src/lib/db/queries/apiKeys.ts create mode 100644 apps/landing/src/lib/db/queries/organizations.ts create mode 100644 apps/landing/src/lib/db/queries/projects.ts delete mode 100644 packages/database/migrations/0000_gifted_sunfire.sql delete mode 100644 packages/database/migrations/meta/0000_snapshot.json create mode 100644 packages/database/src/schema/organizations.ts create mode 100644 packages/database/src/schema/projects.ts diff --git a/.mcp.json b/.mcp.json index 905dcd7..085f810 100644 --- a/.mcp.json +++ b/.mcp.json @@ -25,7 +25,7 @@ "command": "docker", "args": ["run", "-i", "--rm", "-e", "DATABASE_URI", "crystaldba/postgres-mcp", "--access-mode=unrestricted"], "env": { - "DATABASE_URI": "postgresql://postgres:postgres@localhost:5434/banatie_db" + "DATABASE_URI": "postgresql://banatie_user:banatie_secure_password@localhost:5434/banatie_db" } }, "mastra": { diff --git a/apps/landing/package.json b/apps/landing/package.json index 5cb9ebb..f1b3d60 100644 --- a/apps/landing/package.json +++ b/apps/landing/package.json @@ -11,7 +11,8 @@ "dependencies": { "react": "19.1.0", "react-dom": "19.1.0", - "next": "15.5.4" + "next": "15.5.4", + "@banatie/database": "workspace:*" }, "devDependencies": { "typescript": "^5", diff --git a/apps/landing/src/app/admin/apikeys/page.tsx b/apps/landing/src/app/admin/apikeys/page.tsx new file mode 100644 index 0000000..730e3f6 --- /dev/null +++ b/apps/landing/src/app/admin/apikeys/page.tsx @@ -0,0 +1,208 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { useRouter } from 'next/navigation'; +import { createProjectApiKey, listApiKeys } from '@/lib/actions/apiKeyActions'; +import KeyDisplay from '@/components/admin/KeyDisplay'; +import AdminFormInput from '@/components/admin/AdminFormInput'; +import AdminButton from '@/components/admin/AdminButton'; +import Link from 'next/link'; + +const STORAGE_KEY = 'banatie_master_key'; + +export default function ApiKeysPage() { + const router = useRouter(); + const [masterKey, setMasterKey] = useState(''); + const [email, setEmail] = useState(''); + const [orgName, setOrgName] = useState(''); + const [projectName, setProjectName] = useState(''); + const [generatedKey, setGeneratedKey] = useState(''); + const [apiKeys, setApiKeys] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const [success, setSuccess] = useState(''); + + // Check for master key on mount + useEffect(() => { + const saved = localStorage.getItem(STORAGE_KEY); + if (!saved) { + router.push('/admin/master'); + } else { + setMasterKey(saved); + loadApiKeys(); + } + }, [router]); + + const loadApiKeys = async () => { + const keys = await listApiKeys(); + setApiKeys(keys); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setLoading(true); + setError(''); + setSuccess(''); + setGeneratedKey(''); + + const result = await createProjectApiKey(masterKey, email, orgName, projectName); + + if (result.success && result.apiKey) { + setGeneratedKey(result.apiKey); + setSuccess('API key created successfully!'); + // Clear form + setEmail(''); + setOrgName(''); + setProjectName(''); + // Reload keys list + await loadApiKeys(); + } else { + setError(result.error || 'Failed to create API key'); + } + + setLoading(false); + }; + + if (!masterKey) { + return null; // Will redirect + } + + return ( +
+ {/* Navigation */} +
+ + Master Key + + + API Keys + +
+ + {/* Page Header */} +
+

Project API Keys

+

+ Generate API keys for your projects. Organizations and projects will be created automatically if they don't exist. +

+
+ + {/* Messages */} + {error && ( +
+ {error} +
+ )} + {success && ( +
+ {success} +
+ )} + + {/* Generated Key Display */} + {generatedKey && ( +
+

New API Key Generated

+

+ ⚠️ Save this key now! You won't be able to see it again. +

+ +
+ )} + + {/* Create Key Form */} +
+

Create New API Key

+
+ + + + + {loading ? 'Creating...' : 'Create API Key'} + + +
+ + {/* API Keys List */} +
+

Recent API Keys

+ {apiKeys.length === 0 ? ( +

No API keys created yet.

+ ) : ( +
+ + + + + + + + + + + + + {apiKeys.map((key) => ( + + + + + + + + + ))} + +
TypeOrganizationProjectCreatedExpiresStatus
+ + {key.keyType} + + + {key.organizationName || '-'} + {key.organizationEmail && ( +
{key.organizationEmail}
+ )} +
{key.projectName || '-'} + {new Date(key.createdAt).toLocaleDateString()} + + {key.expiresAt ? new Date(key.expiresAt).toLocaleDateString() : 'Never'} + + + {key.isActive ? 'Active' : 'Inactive'} + +
+
+ )} +
+
+ ); +} diff --git a/apps/landing/src/app/admin/master/page.tsx b/apps/landing/src/app/admin/master/page.tsx new file mode 100644 index 0000000..6e6db1f --- /dev/null +++ b/apps/landing/src/app/admin/master/page.tsx @@ -0,0 +1,153 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { bootstrapMasterKey } from '@/lib/actions/apiKeyActions'; +import KeyDisplay from '@/components/admin/KeyDisplay'; +import AdminFormInput from '@/components/admin/AdminFormInput'; +import AdminButton from '@/components/admin/AdminButton'; +import Link from 'next/link'; + +const STORAGE_KEY = 'banatie_master_key'; + +export default function MasterKeyPage() { + const [masterKey, setMasterKey] = useState(''); + const [manualKey, setManualKey] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const [success, setSuccess] = useState(''); + + // Load saved key from localStorage on mount + useEffect(() => { + const saved = localStorage.getItem(STORAGE_KEY); + if (saved) { + setMasterKey(saved); + } + }, []); + + const handleBootstrap = async () => { + setLoading(true); + setError(''); + setSuccess(''); + + const result = await bootstrapMasterKey(); + + if (result.success && result.apiKey) { + setMasterKey(result.apiKey); + setSuccess('Master key generated successfully!'); + } else { + setError(result.error || 'Failed to bootstrap master key'); + } + + setLoading(false); + }; + + const handleSave = () => { + if (masterKey) { + localStorage.setItem(STORAGE_KEY, masterKey); + setSuccess('Master key saved to localStorage!'); + setTimeout(() => setSuccess(''), 3000); + } + }; + + const handleManualSave = () => { + if (manualKey) { + localStorage.setItem(STORAGE_KEY, manualKey); + setMasterKey(manualKey); + setManualKey(''); + setSuccess('Master key saved to localStorage!'); + setTimeout(() => setSuccess(''), 3000); + } + }; + + const handleClear = () => { + localStorage.removeItem(STORAGE_KEY); + setMasterKey(''); + setSuccess('Master key cleared from localStorage'); + setTimeout(() => setSuccess(''), 3000); + }; + + return ( +
+ {/* Navigation */} +
+ + Master Key + + + API Keys + +
+ + {/* Page Header */} +
+

Master Key Management

+

+ Bootstrap your master key or manually configure it. This key is required to generate project API keys. +

+
+ + {/* Messages */} + {error && ( +
+ {error} +
+ )} + {success && ( +
+ {success} +
+ )} + + {/* Bootstrap Section */} +
+

Bootstrap Master Key

+

+ Generate the first master key for your system. This only works if no keys exist yet. +

+ + {loading ? 'Generating...' : 'Generate Master Key'} + +
+ + {/* Current Key Display */} + {masterKey && ( +
+

Current Master Key

+ +
+ + Save to LocalStorage + + + Clear from LocalStorage + +
+
+ )} + + {/* Manual Entry Section */} +
+

Manual Key Entry

+

+ Already have a master key? Enter it here to save it to localStorage. +

+ + + Save Manual Key + +
+
+ ); +} diff --git a/apps/landing/src/app/layout.tsx b/apps/landing/src/app/layout.tsx index 1589506..d1388d2 100644 --- a/apps/landing/src/app/layout.tsx +++ b/apps/landing/src/app/layout.tsx @@ -1,5 +1,6 @@ import type { Metadata } from "next"; import { Inter } from "next/font/google"; +import Image from "next/image"; import "./globals.css"; const inter = Inter({ @@ -58,7 +59,64 @@ export default function RootLayout({ - {children} +
+ {/* Animated gradient background */} +
+
+
+
+ + {/* Header */} +
+ +
+ + {/* Page content */} + {children} + + {/* Footer */} + +
); diff --git a/apps/landing/src/app/page.tsx b/apps/landing/src/app/page.tsx index 52b25c5..894512a 100644 --- a/apps/landing/src/app/page.tsx +++ b/apps/landing/src/app/page.tsx @@ -1,7 +1,6 @@ 'use client'; import { useState, FormEvent } from 'react'; -import Image from 'next/image'; export default function Home() { const [email, setEmail] = useState(''); @@ -21,35 +20,7 @@ export default function Home() { }; return ( -
- {/* Animated gradient background */} -
-
-
-
- - {/* Header */} -
- -
- + <> {/* Hero Section */}
@@ -247,32 +218,6 @@ export default function Home() {
- - {/* Footer */} - -
+ ); } diff --git a/apps/landing/src/components/admin/AdminButton.tsx b/apps/landing/src/components/admin/AdminButton.tsx new file mode 100644 index 0000000..b9265c5 --- /dev/null +++ b/apps/landing/src/components/admin/AdminButton.tsx @@ -0,0 +1,36 @@ +interface AdminButtonProps { + children: React.ReactNode; + onClick?: () => void; + type?: 'button' | 'submit' | 'reset'; + variant?: 'primary' | 'secondary' | 'danger'; + disabled?: boolean; + className?: string; +} + +export default function AdminButton({ + children, + onClick, + type = 'button', + variant = 'primary', + disabled = false, + className = '', +}: AdminButtonProps) { + const baseClasses = 'px-6 py-3 font-semibold rounded-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed'; + + const variantClasses = { + primary: 'bg-amber-600 text-white hover:bg-amber-700 shadow-lg shadow-amber-900/30', + secondary: 'bg-slate-700 text-slate-200 hover:bg-slate-600', + danger: 'bg-red-600 text-white hover:bg-red-700', + }; + + return ( + + ); +} diff --git a/apps/landing/src/components/admin/AdminFormInput.tsx b/apps/landing/src/components/admin/AdminFormInput.tsx new file mode 100644 index 0000000..7e84907 --- /dev/null +++ b/apps/landing/src/components/admin/AdminFormInput.tsx @@ -0,0 +1,36 @@ +interface AdminFormInputProps { + label: string; + type?: string; + value: string; + onChange: (value: string) => void; + placeholder?: string; + required?: boolean; + className?: string; +} + +export default function AdminFormInput({ + label, + type = 'text', + value, + onChange, + placeholder, + required = false, + className = '', +}: AdminFormInputProps) { + return ( +
+ + onChange(e.target.value)} + placeholder={placeholder} + required={required} + className="w-full px-4 py-3 bg-slate-900 border border-slate-700 rounded-lg text-slate-200 placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-amber-500 focus:border-transparent" + /> +
+ ); +} diff --git a/apps/landing/src/components/admin/CopyButton.tsx b/apps/landing/src/components/admin/CopyButton.tsx new file mode 100644 index 0000000..81adf1b --- /dev/null +++ b/apps/landing/src/components/admin/CopyButton.tsx @@ -0,0 +1,36 @@ +'use client'; + +import { useState } from 'react'; + +interface CopyButtonProps { + text: string; + label?: string; + className?: string; +} + +export default function CopyButton({ text, label = 'Copy', className = '' }: CopyButtonProps) { + const [copied, setCopied] = useState(false); + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(text); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch (err) { + console.error('Failed to copy:', err); + } + }; + + return ( + + ); +} diff --git a/apps/landing/src/components/admin/KeyDisplay.tsx b/apps/landing/src/components/admin/KeyDisplay.tsx new file mode 100644 index 0000000..4cdea99 --- /dev/null +++ b/apps/landing/src/components/admin/KeyDisplay.tsx @@ -0,0 +1,39 @@ +'use client'; + +import { useState } from 'react'; +import CopyButton from './CopyButton'; + +interface KeyDisplayProps { + apiKey: string; + label?: string; + className?: string; +} + +export default function KeyDisplay({ apiKey, label = 'API Key', className = '' }: KeyDisplayProps) { + const [revealed, setRevealed] = useState(false); + + const maskedKey = apiKey ? `${apiKey.substring(0, 8)}${'•'.repeat(48)}` : ''; + const displayKey = revealed ? apiKey : maskedKey; + + return ( +
+ +
+
+ {displayKey || 'No key generated yet'} +
+ {apiKey && ( + <> + + + + )} +
+
+ ); +} diff --git a/apps/landing/src/lib/actions/apiKeyActions.ts b/apps/landing/src/lib/actions/apiKeyActions.ts new file mode 100644 index 0000000..e6db8f2 --- /dev/null +++ b/apps/landing/src/lib/actions/apiKeyActions.ts @@ -0,0 +1,96 @@ +'use server'; + +import { getOrCreateOrgAndProject } from './orgProjectActions'; +import { listApiKeys as listApiKeysQuery } from '../db/queries/apiKeys'; + +const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'; + +interface BootstrapResponse { + apiKey: string; + type: string; + name: string; + expiresAt: string | null; + message: string; +} + +interface CreateKeyResponse { + apiKey: string; + metadata: { + id: string; + type: string; + projectId: string; + name: string; + expiresAt: string | null; + scopes: string[]; + createdAt: string; + }; + message: string; +} + +export async function bootstrapMasterKey(): Promise<{ success: boolean; apiKey?: string; error?: string }> { + try { + const response = await fetch(`${API_BASE_URL}/api/bootstrap/initial-key`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + const error = await response.json(); + return { success: false, error: error.message || 'Failed to bootstrap master key' }; + } + + const data: BootstrapResponse = await response.json(); + return { success: true, apiKey: data.apiKey }; + } catch (error) { + console.error('Bootstrap error:', error); + return { success: false, error: 'Network error while bootstrapping master key' }; + } +} + +export async function createProjectApiKey( + masterKey: string, + email: string, + orgName: string, + projectName: string +): Promise<{ success: boolean; apiKey?: string; error?: string }> { + try { + // First, ensure organization and project exist in DB + const { organization, project } = await getOrCreateOrgAndProject(email, orgName, projectName); + + // Then call API service to create the project key + const response = await fetch(`${API_BASE_URL}/api/admin/keys`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-API-Key': masterKey, + }, + body: JSON.stringify({ + type: 'project', + projectId: project.id, + name: `${orgName} - ${projectName}`, + }), + }); + + if (!response.ok) { + const error = await response.json(); + return { success: false, error: error.message || 'Failed to create API key' }; + } + + const data: CreateKeyResponse = await response.json(); + return { success: true, apiKey: data.apiKey }; + } catch (error) { + console.error('Create key error:', error); + return { success: false, error: 'Network error while creating API key' }; + } +} + +export async function listApiKeys() { + try { + return await listApiKeysQuery(); + } catch (error) { + console.error('List keys error:', error); + return []; + } +} diff --git a/apps/landing/src/lib/actions/orgProjectActions.ts b/apps/landing/src/lib/actions/orgProjectActions.ts new file mode 100644 index 0000000..6605743 --- /dev/null +++ b/apps/landing/src/lib/actions/orgProjectActions.ts @@ -0,0 +1,41 @@ +'use server'; + +import { getOrganizationByEmail, createOrganization } from '../db/queries/organizations'; +import { getProjectByName, createProject } from '../db/queries/projects'; +import type { Organization, Project } from '@banatie/database'; + +export async function getOrCreateOrganization(email: string, name: string): Promise { + // Try to find existing organization + const existing = await getOrganizationByEmail(email); + if (existing) { + return existing; + } + + // Create new organization + return createOrganization({ email, name }); +} + +export async function getOrCreateProject(organizationId: string, name: string): Promise { + // Try to find existing project + const existing = await getProjectByName(organizationId, name); + if (existing) { + return existing; + } + + // Create new project + return createProject({ organizationId, name }); +} + +export async function getOrCreateOrgAndProject( + email: string, + orgName: string, + projectName: string +): Promise<{ organization: Organization; project: Project }> { + // Get or create organization + const organization = await getOrCreateOrganization(email, orgName); + + // Get or create project + const project = await getOrCreateProject(organization.id, projectName); + + return { organization, project }; +} diff --git a/apps/landing/src/lib/db/client.ts b/apps/landing/src/lib/db/client.ts new file mode 100644 index 0000000..ca02630 --- /dev/null +++ b/apps/landing/src/lib/db/client.ts @@ -0,0 +1,11 @@ +import { drizzle } from 'drizzle-orm/postgres-js'; +import postgres from 'postgres'; +import * as schema from '@banatie/database'; + +const connectionString = process.env.DATABASE_URL || 'postgresql://banatie_user:banatie_secure_password@localhost:5434/banatie_db'; + +// Create postgres client +const client = postgres(connectionString); + +// Create drizzle instance with schema +export const db = drizzle(client, { schema }); diff --git a/apps/landing/src/lib/db/queries/apiKeys.ts b/apps/landing/src/lib/db/queries/apiKeys.ts new file mode 100644 index 0000000..4962ae1 --- /dev/null +++ b/apps/landing/src/lib/db/queries/apiKeys.ts @@ -0,0 +1,35 @@ +import { db } from '../client'; +import { apiKeys, organizations, projects, type ApiKey } from '@banatie/database'; +import { eq, desc } from 'drizzle-orm'; + +export async function listApiKeys() { + return db + .select({ + id: apiKeys.id, + keyType: apiKeys.keyType, + name: apiKeys.name, + scopes: apiKeys.scopes, + isActive: apiKeys.isActive, + createdAt: apiKeys.createdAt, + expiresAt: apiKeys.expiresAt, + lastUsedAt: apiKeys.lastUsedAt, + organizationId: apiKeys.organizationId, + organizationName: organizations.name, + organizationEmail: organizations.email, + projectId: apiKeys.projectId, + projectName: projects.name, + }) + .from(apiKeys) + .leftJoin(organizations, eq(apiKeys.organizationId, organizations.id)) + .leftJoin(projects, eq(apiKeys.projectId, projects.id)) + .orderBy(desc(apiKeys.createdAt)) + .limit(50); +} + +export async function getApiKeysByProject(projectId: string) { + return db + .select() + .from(apiKeys) + .where(eq(apiKeys.projectId, projectId)) + .orderBy(desc(apiKeys.createdAt)); +} diff --git a/apps/landing/src/lib/db/queries/organizations.ts b/apps/landing/src/lib/db/queries/organizations.ts new file mode 100644 index 0000000..197165f --- /dev/null +++ b/apps/landing/src/lib/db/queries/organizations.ts @@ -0,0 +1,29 @@ +import { db } from '../client'; +import { organizations, type Organization, type NewOrganization } from '@banatie/database'; +import { eq } from 'drizzle-orm'; + +export async function getOrganizationByEmail(email: string): Promise { + const [org] = await db + .select() + .from(organizations) + .where(eq(organizations.email, email)) + .limit(1); + + return org || null; +} + +export async function createOrganization(data: NewOrganization): Promise { + const [org] = await db + .insert(organizations) + .values(data) + .returning(); + + return org!; +} + +export async function listOrganizations(): Promise { + return db + .select() + .from(organizations) + .orderBy(organizations.createdAt); +} diff --git a/apps/landing/src/lib/db/queries/projects.ts b/apps/landing/src/lib/db/queries/projects.ts new file mode 100644 index 0000000..f328886 --- /dev/null +++ b/apps/landing/src/lib/db/queries/projects.ts @@ -0,0 +1,35 @@ +import { db } from '../client'; +import { projects, type Project, type NewProject } from '@banatie/database'; +import { eq, and } from 'drizzle-orm'; + +export async function getProjectByName(organizationId: string, name: string): Promise { + const [project] = await db + .select() + .from(projects) + .where( + and( + eq(projects.organizationId, organizationId), + eq(projects.name, name) + ) + ) + .limit(1); + + return project || null; +} + +export async function createProject(data: NewProject): Promise { + const [project] = await db + .insert(projects) + .values(data) + .returning(); + + return project!; +} + +export async function listProjectsByOrganization(organizationId: string): Promise { + return db + .select() + .from(projects) + .where(eq(projects.organizationId, organizationId)) + .orderBy(projects.createdAt); +} diff --git a/packages/database/migrations/0000_gifted_sunfire.sql b/packages/database/migrations/0000_gifted_sunfire.sql deleted file mode 100644 index cc9d65b..0000000 --- a/packages/database/migrations/0000_gifted_sunfire.sql +++ /dev/null @@ -1,15 +0,0 @@ -CREATE TABLE IF NOT EXISTS "api_keys" ( - "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, - "key_hash" text NOT NULL, - "key_prefix" text DEFAULT 'bnt_' NOT NULL, - "key_type" text NOT NULL, - "project_id" text, - "scopes" jsonb DEFAULT '["generate"]'::jsonb NOT NULL, - "created_at" timestamp DEFAULT now() NOT NULL, - "expires_at" timestamp, - "last_used_at" timestamp, - "is_active" boolean DEFAULT true NOT NULL, - "name" text, - "created_by" uuid, - CONSTRAINT "api_keys_key_hash_unique" UNIQUE("key_hash") -); diff --git a/packages/database/migrations/meta/0000_snapshot.json b/packages/database/migrations/meta/0000_snapshot.json deleted file mode 100644 index 663dc41..0000000 --- a/packages/database/migrations/meta/0000_snapshot.json +++ /dev/null @@ -1,117 +0,0 @@ -{ - "id": "a0f532c8-8e34-4297-a580-060eb8b49306", - "prevId": "00000000-0000-0000-0000-000000000000", - "version": "7", - "dialect": "postgresql", - "tables": { - "public.api_keys": { - "name": "api_keys", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "key_hash": { - "name": "key_hash", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "key_prefix": { - "name": "key_prefix", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "'bnt_'" - }, - "key_type": { - "name": "key_type", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "project_id": { - "name": "project_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "scopes": { - "name": "scopes", - "type": "jsonb", - "primaryKey": false, - "notNull": true, - "default": "'[\"generate\"]'::jsonb" - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "expires_at": { - "name": "expires_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false - }, - "last_used_at": { - "name": "last_used_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false - }, - "is_active": { - "name": "is_active", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_by": { - "name": "created_by", - "type": "uuid", - "primaryKey": false, - "notNull": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "api_keys_key_hash_unique": { - "name": "api_keys_key_hash_unique", - "nullsNotDistinct": false, - "columns": [ - "key_hash" - ] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - } - }, - "enums": {}, - "schemas": {}, - "sequences": {}, - "roles": {}, - "policies": {}, - "views": {}, - "_meta": { - "columns": {}, - "schemas": {}, - "tables": {} - } -} \ No newline at end of file diff --git a/packages/database/migrations/meta/_journal.json b/packages/database/migrations/meta/_journal.json index 5fbfbc1..eaa8fcf 100644 --- a/packages/database/migrations/meta/_journal.json +++ b/packages/database/migrations/meta/_journal.json @@ -1,13 +1,5 @@ { "version": "7", "dialect": "postgresql", - "entries": [ - { - "idx": 0, - "version": "7", - "when": 1759250997369, - "tag": "0000_gifted_sunfire", - "breakpoints": true - } - ] + "entries": [] } \ No newline at end of file diff --git a/packages/database/src/schema/apiKeys.ts b/packages/database/src/schema/apiKeys.ts index ef889b7..41e6d25 100644 --- a/packages/database/src/schema/apiKeys.ts +++ b/packages/database/src/schema/apiKeys.ts @@ -1,4 +1,6 @@ import { pgTable, uuid, text, timestamp, boolean, jsonb } from 'drizzle-orm/pg-core'; +import { organizations } from './organizations'; +import { projects } from './projects'; export const apiKeys = pgTable('api_keys', { id: uuid('id').primaryKey().defaultRandom(), @@ -10,8 +12,9 @@ export const apiKeys = pgTable('api_keys', { // Key type: 'master' or 'project' keyType: text('key_type').notNull().$type<'master' | 'project'>(), - // For project keys - projectId: text('project_id'), + // Foreign key relationships + organizationId: uuid('organization_id').references(() => organizations.id, { onDelete: 'cascade' }), + projectId: uuid('project_id').references(() => projects.id, { onDelete: 'cascade' }), // Permissions (for future use) scopes: jsonb('scopes').$type().notNull().default(['generate']), diff --git a/packages/database/src/schema/index.ts b/packages/database/src/schema/index.ts index 68cc1ec..1388b50 100644 --- a/packages/database/src/schema/index.ts +++ b/packages/database/src/schema/index.ts @@ -1 +1,34 @@ -export * from './apiKeys'; \ No newline at end of file +import { relations } from 'drizzle-orm'; +import { organizations } from './organizations'; +import { projects } from './projects'; +import { apiKeys } from './apiKeys'; + +// Export all tables +export * from './organizations'; +export * from './projects'; +export * from './apiKeys'; + +// Define relations +export const organizationsRelations = relations(organizations, ({ many }) => ({ + projects: many(projects), + apiKeys: many(apiKeys), +})); + +export const projectsRelations = relations(projects, ({ one, many }) => ({ + organization: one(organizations, { + fields: [projects.organizationId], + references: [organizations.id], + }), + apiKeys: many(apiKeys), +})); + +export const apiKeysRelations = relations(apiKeys, ({ one }) => ({ + organization: one(organizations, { + fields: [apiKeys.organizationId], + references: [organizations.id], + }), + project: one(projects, { + fields: [apiKeys.projectId], + references: [projects.id], + }), +})); \ No newline at end of file diff --git a/packages/database/src/schema/organizations.ts b/packages/database/src/schema/organizations.ts new file mode 100644 index 0000000..d89d471 --- /dev/null +++ b/packages/database/src/schema/organizations.ts @@ -0,0 +1,16 @@ +import { pgTable, uuid, text, timestamp } from 'drizzle-orm/pg-core'; + +export const organizations = pgTable('organizations', { + id: uuid('id').primaryKey().defaultRandom(), + + // Organization details + name: text('name').notNull(), + email: text('email').notNull().unique(), + + // Timestamps + createdAt: timestamp('created_at').notNull().defaultNow(), + updatedAt: timestamp('updated_at').notNull().defaultNow().$onUpdate(() => new Date()), +}); + +export type Organization = typeof organizations.$inferSelect; +export type NewOrganization = typeof organizations.$inferInsert; diff --git a/packages/database/src/schema/projects.ts b/packages/database/src/schema/projects.ts new file mode 100644 index 0000000..6ab823c --- /dev/null +++ b/packages/database/src/schema/projects.ts @@ -0,0 +1,20 @@ +import { pgTable, uuid, text, timestamp, unique } from 'drizzle-orm/pg-core'; +import { organizations } from './organizations'; + +export const projects = pgTable('projects', { + id: uuid('id').primaryKey().defaultRandom(), + + // Project details + name: text('name').notNull(), + organizationId: uuid('organization_id').notNull().references(() => organizations.id, { onDelete: 'cascade' }), + + // Timestamps + createdAt: timestamp('created_at').notNull().defaultNow(), + updatedAt: timestamp('updated_at').notNull().defaultNow().$onUpdate(() => new Date()), +}, (table) => ({ + // Unique constraint: one project name per organization + uniqueOrgProject: unique().on(table.organizationId, table.name), +})); + +export type Project = typeof projects.$inferSelect; +export type NewProject = typeof projects.$inferInsert;