From bfff9b49ece035d49fcd80b3082409a8bbc694a8 Mon Sep 17 00:00:00 2001 From: Oleg Proskurin Date: Sun, 5 Oct 2025 18:45:15 +0700 Subject: [PATCH] fix: org project and apikey creating --- .../src/middleware/auth/validateApiKey.ts | 7 +- apps/api-service/src/routes/admin/keys.ts | 52 +++- apps/api-service/src/routes/generate.ts | 6 +- apps/api-service/src/routes/textToImage.ts | 6 +- .../api-service/src/services/ApiKeyService.ts | 126 +++++++- apps/landing/src/app/admin/apikeys/page.tsx | 36 +-- apps/landing/src/lib/actions/apiKeyActions.ts | 17 +- .../migrations/0000_curious_wolfsbane.sql | 55 ++++ .../migrations/meta/0000_snapshot.json | 292 ++++++++++++++++++ .../database/migrations/meta/_journal.json | 10 +- packages/database/src/schema/organizations.ts | 1 + packages/database/src/schema/projects.ts | 5 +- 12 files changed, 547 insertions(+), 66 deletions(-) create mode 100644 packages/database/migrations/0000_curious_wolfsbane.sql create mode 100644 packages/database/migrations/meta/0000_snapshot.json diff --git a/apps/api-service/src/middleware/auth/validateApiKey.ts b/apps/api-service/src/middleware/auth/validateApiKey.ts index fb2260c..6652d8c 100644 --- a/apps/api-service/src/middleware/auth/validateApiKey.ts +++ b/apps/api-service/src/middleware/auth/validateApiKey.ts @@ -1,12 +1,11 @@ import { Request, Response, NextFunction } from 'express'; -import { ApiKeyService } from '../../services/ApiKeyService'; -import type { ApiKey } from '@banatie/database'; +import { ApiKeyService, type ApiKeyWithSlugs } from '../../services/ApiKeyService'; -// Extend Express Request type to include apiKey +// Extend Express Request type to include apiKey with slugs declare global { namespace Express { interface Request { - apiKey?: ApiKey; + apiKey?: ApiKeyWithSlugs; } } } diff --git a/apps/api-service/src/routes/admin/keys.ts b/apps/api-service/src/routes/admin/keys.ts index eb91c5a..474f693 100644 --- a/apps/api-service/src/routes/admin/keys.ts +++ b/apps/api-service/src/routes/admin/keys.ts @@ -16,7 +16,17 @@ router.use(requireMasterKey); */ router.post('/', async (req, res) => { try { - const { type, projectId, organizationId, name, expiresInDays } = req.body; + const { + type, + projectId, + organizationId, + organizationSlug, + projectSlug, + organizationName, + projectName, + name, + expiresInDays + } = req.body; // Validation if (!type || !['master', 'project'].includes(type)) { @@ -26,23 +36,46 @@ router.post('/', async (req, res) => { }); } - if (type === 'project' && !projectId) { + if (type === 'project' && !projectSlug) { return res.status(400).json({ - error: 'Missing projectId', - message: 'Project keys require a projectId', + error: 'Missing projectSlug', + message: 'Project keys require a projectSlug', + }); + } + + if (type === 'project' && !organizationSlug) { + return res.status(400).json({ + error: 'Missing organizationSlug', + message: 'Project keys require an organizationSlug', }); } // Create key - const result = type === 'master' - ? await apiKeyService.createMasterKey(name, req.apiKey!.id) - : await apiKeyService.createProjectKey( - projectId, - organizationId, + let result; + + if (type === 'master') { + result = await apiKeyService.createMasterKey(name, req.apiKey!.id); + } else { + // Get or create organization and project + const finalOrgId = await apiKeyService.getOrCreateOrganization( + organizationSlug, + organizationName, + ); + + const finalProjectId = await apiKeyService.getOrCreateProject( + finalOrgId, + projectSlug, + projectName, + ); + + result = await apiKeyService.createProjectKey( + finalProjectId, + finalOrgId, name, req.apiKey!.id, expiresInDays || 90 ); + } console.log(`[${new Date().toISOString()}] New API key created by admin: ${result.metadata.id} (${result.metadata.keyType}) - by: ${req.apiKey!.id}`); @@ -52,6 +85,7 @@ router.post('/', async (req, res) => { id: result.metadata.id, type: result.metadata.keyType, projectId: result.metadata.projectId, + organizationId: result.metadata.organizationId, name: result.metadata.name, expiresAt: result.metadata.expiresAt, scopes: result.metadata.scopes, diff --git a/apps/api-service/src/routes/generate.ts b/apps/api-service/src/routes/generate.ts index 3160b11..d8274ee 100644 --- a/apps/api-service/src/routes/generate.ts +++ b/apps/api-service/src/routes/generate.ts @@ -66,9 +66,9 @@ generateRouter.post( const { prompt, filename } = req.body; const files = (req.files as Express.Multer.File[]) || []; - // Extract org/project IDs from validated API key - const orgId = req.apiKey?.organizationId || undefined; - const projectId = req.apiKey?.projectId!; // Guaranteed by requireProjectKey middleware + // Extract org/project slugs from validated API key + const orgId = req.apiKey?.organizationSlug || undefined; + const projectId = req.apiKey?.projectSlug!; // Guaranteed by requireProjectKey middleware console.log( `[${timestamp}] [${requestId}] Starting image generation process for org:${orgId}, project:${projectId}`, diff --git a/apps/api-service/src/routes/textToImage.ts b/apps/api-service/src/routes/textToImage.ts index 6563a3e..594e5db 100644 --- a/apps/api-service/src/routes/textToImage.ts +++ b/apps/api-service/src/routes/textToImage.ts @@ -56,9 +56,9 @@ textToImageRouter.post( const requestId = req.requestId; const { prompt, filename } = req.body; - // Extract org/project IDs from validated API key - const orgId = req.apiKey?.organizationId || undefined; - const projectId = req.apiKey?.projectId!; // Guaranteed by requireProjectKey middleware + // Extract org/project slugs from validated API key + const orgId = req.apiKey?.organizationSlug || undefined; + const projectId = req.apiKey?.projectSlug!; // Guaranteed by requireProjectKey middleware console.log( `[${timestamp}] [${requestId}] Starting text-to-image generation process for org:${orgId}, project:${projectId}`, diff --git a/apps/api-service/src/services/ApiKeyService.ts b/apps/api-service/src/services/ApiKeyService.ts index 87c9842..c6aee96 100644 --- a/apps/api-service/src/services/ApiKeyService.ts +++ b/apps/api-service/src/services/ApiKeyService.ts @@ -1,8 +1,14 @@ import crypto from "crypto"; import { db } from "../db"; -import { apiKeys, type ApiKey, type NewApiKey } from "@banatie/database"; +import { apiKeys, organizations, projects, type ApiKey, type NewApiKey } from "@banatie/database"; import { eq, and, desc } from "drizzle-orm"; +// Extended API key type with slugs for storage paths +export interface ApiKeyWithSlugs extends ApiKey { + organizationSlug?: string; + projectSlug?: string; +} + export class ApiKeyService { /** * Generate a new API key @@ -93,8 +99,9 @@ export class ApiKeyService { /** * Validate an API key * Returns null if invalid/expired/revoked + * Returns API key with organization and project slugs for storage paths */ - async validateKey(providedKey: string): Promise { + async validateKey(providedKey: string): Promise { if (!providedKey || !providedKey.startsWith("bnt_")) { return null; } @@ -105,14 +112,34 @@ export class ApiKeyService { .update(providedKey) .digest("hex"); - // Find in database - const [key] = await db - .select() + // Find in database with left joins to get slugs + const [result] = await db + .select({ + // API key fields + id: apiKeys.id, + keyHash: apiKeys.keyHash, + keyPrefix: apiKeys.keyPrefix, + keyType: apiKeys.keyType, + organizationId: apiKeys.organizationId, + projectId: apiKeys.projectId, + scopes: apiKeys.scopes, + createdAt: apiKeys.createdAt, + expiresAt: apiKeys.expiresAt, + lastUsedAt: apiKeys.lastUsedAt, + isActive: apiKeys.isActive, + name: apiKeys.name, + createdBy: apiKeys.createdBy, + // Slug fields + organizationSlug: organizations.slug, + projectSlug: projects.slug, + }) .from(apiKeys) + .leftJoin(organizations, eq(apiKeys.organizationId, organizations.id)) + .leftJoin(projects, eq(apiKeys.projectId, projects.id)) .where(and(eq(apiKeys.keyHash, keyHash), eq(apiKeys.isActive, true))) .limit(1); - if (!key) { + if (!result) { console.warn( `[${new Date().toISOString()}] Invalid API key attempt: ${providedKey.substring(0, 10)}...`, ); @@ -120,9 +147,9 @@ export class ApiKeyService { } // Check expiration - if (key.expiresAt && key.expiresAt < new Date()) { + if (result.expiresAt && result.expiresAt < new Date()) { console.warn( - `[${new Date().toISOString()}] Expired API key used: ${key.id} - expired: ${key.expiresAt.toISOString()}`, + `[${new Date().toISOString()}] Expired API key used: ${result.id} - expired: ${result.expiresAt.toISOString()}`, ); return null; } @@ -130,7 +157,7 @@ export class ApiKeyService { // Update last used timestamp (async, don't wait) db.update(apiKeys) .set({ lastUsedAt: new Date() }) - .where(eq(apiKeys.id, key.id)) + .where(eq(apiKeys.id, result.id)) .execute() .catch((err) => console.error( @@ -139,7 +166,7 @@ export class ApiKeyService { ), ); - return key; + return result as ApiKeyWithSlugs; } /** @@ -175,4 +202,83 @@ export class ApiKeyService { return keys.length > 0; } + + /** + * Get or create organization by slug + * If organization doesn't exist, create it with provided name (or use slug as name) + */ + async getOrCreateOrganization( + slug: string, + name?: string, + email?: string, + ): Promise { + // Try to find existing organization + const [existing] = await db + .select({ id: organizations.id }) + .from(organizations) + .where(eq(organizations.slug, slug)) + .limit(1); + + if (existing) { + return existing.id; + } + + // Create new organization + const [newOrg] = await db + .insert(organizations) + .values({ + slug, + name: name || slug, + email: email || `${slug}@placeholder.local`, + }) + .returning({ id: organizations.id }); + + console.log( + `[${new Date().toISOString()}] Organization created: ${newOrg?.id} - ${slug}`, + ); + + return newOrg!.id; + } + + /** + * Get or create project by slug within an organization + * If project doesn't exist, create it with provided name (or use slug as name) + */ + async getOrCreateProject( + organizationId: string, + slug: string, + name?: string, + ): Promise { + // Try to find existing project + const [existing] = await db + .select({ id: projects.id }) + .from(projects) + .where( + and( + eq(projects.organizationId, organizationId), + eq(projects.slug, slug), + ), + ) + .limit(1); + + if (existing) { + return existing.id; + } + + // Create new project + const [newProject] = await db + .insert(projects) + .values({ + organizationId, + slug, + name: name || slug, + }) + .returning({ id: projects.id }); + + console.log( + `[${new Date().toISOString()}] Project created: ${newProject?.id} - ${slug} (org: ${organizationId})`, + ); + + return newProject!.id; + } } diff --git a/apps/landing/src/app/admin/apikeys/page.tsx b/apps/landing/src/app/admin/apikeys/page.tsx index 730e3f6..a665533 100644 --- a/apps/landing/src/app/admin/apikeys/page.tsx +++ b/apps/landing/src/app/admin/apikeys/page.tsx @@ -13,9 +13,8 @@ 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 [orgSlug, setOrgSlug] = useState(''); + const [projectSlug, setProjectSlug] = useState(''); const [generatedKey, setGeneratedKey] = useState(''); const [apiKeys, setApiKeys] = useState([]); const [loading, setLoading] = useState(false); @@ -45,15 +44,14 @@ export default function ApiKeysPage() { setSuccess(''); setGeneratedKey(''); - const result = await createProjectApiKey(masterKey, email, orgName, projectName); + const result = await createProjectApiKey(masterKey, orgSlug, projectSlug); if (result.success && result.apiKey) { setGeneratedKey(result.apiKey); setSuccess('API key created successfully!'); // Clear form - setEmail(''); - setOrgName(''); - setProjectName(''); + setOrgSlug(''); + setProjectSlug(''); // Reload keys list await loadApiKeys(); } else { @@ -121,25 +119,17 @@ export default function ApiKeysPage() {

Create New API Key

- diff --git a/apps/landing/src/lib/actions/apiKeyActions.ts b/apps/landing/src/lib/actions/apiKeyActions.ts index e655eb0..3dd7379 100644 --- a/apps/landing/src/lib/actions/apiKeyActions.ts +++ b/apps/landing/src/lib/actions/apiKeyActions.ts @@ -1,6 +1,5 @@ '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'; @@ -51,15 +50,11 @@ export async function bootstrapMasterKey(): Promise<{ success: boolean; apiKey?: export async function createProjectApiKey( masterKey: string, - email: string, - orgName: string, - projectName: string + orgSlug: string, + projectSlug: 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 + // Call API service to create the project key (API auto-creates org/project) const response = await fetch(`${API_BASE_URL}/api/admin/keys`, { method: 'POST', headers: { @@ -68,9 +63,9 @@ export async function createProjectApiKey( }, body: JSON.stringify({ type: 'project', - projectId: project.id, - organizationId: organization.id, - name: `${orgName} - ${projectName}`, + organizationSlug: orgSlug, + projectSlug: projectSlug, + name: `${orgSlug} - ${projectSlug}`, }), }); diff --git a/packages/database/migrations/0000_curious_wolfsbane.sql b/packages/database/migrations/0000_curious_wolfsbane.sql new file mode 100644 index 0000000..77b7d37 --- /dev/null +++ b/packages/database/migrations/0000_curious_wolfsbane.sql @@ -0,0 +1,55 @@ +CREATE TABLE IF NOT EXISTS "organizations" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "name" text NOT NULL, + "slug" text NOT NULL, + "email" text NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "organizations_slug_unique" UNIQUE("slug"), + CONSTRAINT "organizations_email_unique" UNIQUE("email") +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "projects" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "name" text NOT NULL, + "slug" text NOT NULL, + "organization_id" uuid NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "projects_organization_id_slug_unique" UNIQUE("organization_id","slug") +); +--> statement-breakpoint +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, + "organization_id" uuid, + "project_id" uuid, + "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") +); +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "projects" ADD CONSTRAINT "projects_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "api_keys" ADD CONSTRAINT "api_keys_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "api_keys" ADD CONSTRAINT "api_keys_project_id_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; diff --git a/packages/database/migrations/meta/0000_snapshot.json b/packages/database/migrations/meta/0000_snapshot.json new file mode 100644 index 0000000..fa89e58 --- /dev/null +++ b/packages/database/migrations/meta/0000_snapshot.json @@ -0,0 +1,292 @@ +{ + "id": "8ec6e31f-1daa-4930-8bf8-2b4996e17270", + "prevId": "00000000-0000-0000-0000-000000000000", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.organizations": { + "name": "organizations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "organizations_slug_unique": { + "name": "organizations_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + }, + "organizations_email_unique": { + "name": "organizations_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.projects": { + "name": "projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "projects_organization_id_organizations_id_fk": { + "name": "projects_organization_id_organizations_id_fk", + "tableFrom": "projects", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "projects_organization_id_slug_unique": { + "name": "projects_organization_id_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "organization_id", + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "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 + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "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": { + "api_keys_organization_id_organizations_id_fk": { + "name": "api_keys_organization_id_organizations_id_fk", + "tableFrom": "api_keys", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_keys_project_id_projects_id_fk": { + "name": "api_keys_project_id_projects_id_fk", + "tableFrom": "api_keys", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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 eaa8fcf..65a375f 100644 --- a/packages/database/migrations/meta/_journal.json +++ b/packages/database/migrations/meta/_journal.json @@ -1,5 +1,13 @@ { "version": "7", "dialect": "postgresql", - "entries": [] + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1759661399219, + "tag": "0000_curious_wolfsbane", + "breakpoints": true + } + ] } \ No newline at end of file diff --git a/packages/database/src/schema/organizations.ts b/packages/database/src/schema/organizations.ts index d89d471..9cb9cb4 100644 --- a/packages/database/src/schema/organizations.ts +++ b/packages/database/src/schema/organizations.ts @@ -5,6 +5,7 @@ export const organizations = pgTable('organizations', { // Organization details name: text('name').notNull(), + slug: text('slug').notNull().unique(), // URL-friendly identifier for storage paths email: text('email').notNull().unique(), // Timestamps diff --git a/packages/database/src/schema/projects.ts b/packages/database/src/schema/projects.ts index 6ab823c..2ebdd7a 100644 --- a/packages/database/src/schema/projects.ts +++ b/packages/database/src/schema/projects.ts @@ -6,14 +6,15 @@ export const projects = pgTable('projects', { // Project details name: text('name').notNull(), + slug: text('slug').notNull(), // URL-friendly identifier for storage paths 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), + // Unique constraint: one project slug per organization + uniqueOrgProjectSlug: unique().on(table.organizationId, table.slug), })); export type Project = typeof projects.$inferSelect;