fix: org project and apikey creating

This commit is contained in:
Oleg Proskurin 2025-10-05 18:45:15 +07:00
parent bdf2c80782
commit bfff9b49ec
12 changed files with 547 additions and 66 deletions

View File

@ -1,12 +1,11 @@
import { Request, Response, NextFunction } from 'express'; import { Request, Response, NextFunction } from 'express';
import { ApiKeyService } from '../../services/ApiKeyService'; import { ApiKeyService, type ApiKeyWithSlugs } from '../../services/ApiKeyService';
import type { ApiKey } from '@banatie/database';
// Extend Express Request type to include apiKey // Extend Express Request type to include apiKey with slugs
declare global { declare global {
namespace Express { namespace Express {
interface Request { interface Request {
apiKey?: ApiKey; apiKey?: ApiKeyWithSlugs;
} }
} }
} }

View File

@ -16,7 +16,17 @@ router.use(requireMasterKey);
*/ */
router.post('/', async (req, res) => { router.post('/', async (req, res) => {
try { try {
const { type, projectId, organizationId, name, expiresInDays } = req.body; const {
type,
projectId,
organizationId,
organizationSlug,
projectSlug,
organizationName,
projectName,
name,
expiresInDays
} = req.body;
// Validation // Validation
if (!type || !['master', 'project'].includes(type)) { 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({ return res.status(400).json({
error: 'Missing projectId', error: 'Missing projectSlug',
message: 'Project keys require a projectId', 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 // Create key
const result = type === 'master' let result;
? await apiKeyService.createMasterKey(name, req.apiKey!.id)
: await apiKeyService.createProjectKey( if (type === 'master') {
projectId, result = await apiKeyService.createMasterKey(name, req.apiKey!.id);
organizationId, } 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, name,
req.apiKey!.id, req.apiKey!.id,
expiresInDays || 90 expiresInDays || 90
); );
}
console.log(`[${new Date().toISOString()}] New API key created by admin: ${result.metadata.id} (${result.metadata.keyType}) - by: ${req.apiKey!.id}`); 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, id: result.metadata.id,
type: result.metadata.keyType, type: result.metadata.keyType,
projectId: result.metadata.projectId, projectId: result.metadata.projectId,
organizationId: result.metadata.organizationId,
name: result.metadata.name, name: result.metadata.name,
expiresAt: result.metadata.expiresAt, expiresAt: result.metadata.expiresAt,
scopes: result.metadata.scopes, scopes: result.metadata.scopes,

View File

@ -66,9 +66,9 @@ generateRouter.post(
const { prompt, filename } = req.body; const { prompt, filename } = req.body;
const files = (req.files as Express.Multer.File[]) || []; const files = (req.files as Express.Multer.File[]) || [];
// Extract org/project IDs from validated API key // Extract org/project slugs from validated API key
const orgId = req.apiKey?.organizationId || undefined; const orgId = req.apiKey?.organizationSlug || undefined;
const projectId = req.apiKey?.projectId!; // Guaranteed by requireProjectKey middleware const projectId = req.apiKey?.projectSlug!; // Guaranteed by requireProjectKey middleware
console.log( console.log(
`[${timestamp}] [${requestId}] Starting image generation process for org:${orgId}, project:${projectId}`, `[${timestamp}] [${requestId}] Starting image generation process for org:${orgId}, project:${projectId}`,

View File

@ -56,9 +56,9 @@ textToImageRouter.post(
const requestId = req.requestId; const requestId = req.requestId;
const { prompt, filename } = req.body; const { prompt, filename } = req.body;
// Extract org/project IDs from validated API key // Extract org/project slugs from validated API key
const orgId = req.apiKey?.organizationId || undefined; const orgId = req.apiKey?.organizationSlug || undefined;
const projectId = req.apiKey?.projectId!; // Guaranteed by requireProjectKey middleware const projectId = req.apiKey?.projectSlug!; // Guaranteed by requireProjectKey middleware
console.log( console.log(
`[${timestamp}] [${requestId}] Starting text-to-image generation process for org:${orgId}, project:${projectId}`, `[${timestamp}] [${requestId}] Starting text-to-image generation process for org:${orgId}, project:${projectId}`,

View File

@ -1,8 +1,14 @@
import crypto from "crypto"; import crypto from "crypto";
import { db } from "../db"; 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"; 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 { export class ApiKeyService {
/** /**
* Generate a new API key * Generate a new API key
@ -93,8 +99,9 @@ export class ApiKeyService {
/** /**
* Validate an API key * Validate an API key
* Returns null if invalid/expired/revoked * Returns null if invalid/expired/revoked
* Returns API key with organization and project slugs for storage paths
*/ */
async validateKey(providedKey: string): Promise<ApiKey | null> { async validateKey(providedKey: string): Promise<ApiKeyWithSlugs | null> {
if (!providedKey || !providedKey.startsWith("bnt_")) { if (!providedKey || !providedKey.startsWith("bnt_")) {
return null; return null;
} }
@ -105,14 +112,34 @@ export class ApiKeyService {
.update(providedKey) .update(providedKey)
.digest("hex"); .digest("hex");
// Find in database // Find in database with left joins to get slugs
const [key] = await db const [result] = await db
.select() .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) .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))) .where(and(eq(apiKeys.keyHash, keyHash), eq(apiKeys.isActive, true)))
.limit(1); .limit(1);
if (!key) { if (!result) {
console.warn( console.warn(
`[${new Date().toISOString()}] Invalid API key attempt: ${providedKey.substring(0, 10)}...`, `[${new Date().toISOString()}] Invalid API key attempt: ${providedKey.substring(0, 10)}...`,
); );
@ -120,9 +147,9 @@ export class ApiKeyService {
} }
// Check expiration // Check expiration
if (key.expiresAt && key.expiresAt < new Date()) { if (result.expiresAt && result.expiresAt < new Date()) {
console.warn( 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; return null;
} }
@ -130,7 +157,7 @@ export class ApiKeyService {
// Update last used timestamp (async, don't wait) // Update last used timestamp (async, don't wait)
db.update(apiKeys) db.update(apiKeys)
.set({ lastUsedAt: new Date() }) .set({ lastUsedAt: new Date() })
.where(eq(apiKeys.id, key.id)) .where(eq(apiKeys.id, result.id))
.execute() .execute()
.catch((err) => .catch((err) =>
console.error( 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; 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<string> {
// 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<string> {
// 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;
}
} }

View File

@ -13,9 +13,8 @@ const STORAGE_KEY = 'banatie_master_key';
export default function ApiKeysPage() { export default function ApiKeysPage() {
const router = useRouter(); const router = useRouter();
const [masterKey, setMasterKey] = useState(''); const [masterKey, setMasterKey] = useState('');
const [email, setEmail] = useState(''); const [orgSlug, setOrgSlug] = useState('');
const [orgName, setOrgName] = useState(''); const [projectSlug, setProjectSlug] = useState('');
const [projectName, setProjectName] = useState('');
const [generatedKey, setGeneratedKey] = useState(''); const [generatedKey, setGeneratedKey] = useState('');
const [apiKeys, setApiKeys] = useState<any[]>([]); const [apiKeys, setApiKeys] = useState<any[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@ -45,15 +44,14 @@ export default function ApiKeysPage() {
setSuccess(''); setSuccess('');
setGeneratedKey(''); setGeneratedKey('');
const result = await createProjectApiKey(masterKey, email, orgName, projectName); const result = await createProjectApiKey(masterKey, orgSlug, projectSlug);
if (result.success && result.apiKey) { if (result.success && result.apiKey) {
setGeneratedKey(result.apiKey); setGeneratedKey(result.apiKey);
setSuccess('API key created successfully!'); setSuccess('API key created successfully!');
// Clear form // Clear form
setEmail(''); setOrgSlug('');
setOrgName(''); setProjectSlug('');
setProjectName('');
// Reload keys list // Reload keys list
await loadApiKeys(); await loadApiKeys();
} else { } else {
@ -121,25 +119,17 @@ export default function ApiKeysPage() {
<h2 className="text-2xl font-semibold text-white mb-6">Create New API Key</h2> <h2 className="text-2xl font-semibold text-white mb-6">Create New API Key</h2>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<AdminFormInput <AdminFormInput
label="Email" label="Organization Slug"
type="email" value={orgSlug}
value={email} onChange={setOrgSlug}
onChange={setEmail} placeholder="my-org"
placeholder="admin@example.com"
required required
/> />
<AdminFormInput <AdminFormInput
label="Organization Name" label="Project Slug"
value={orgName} value={projectSlug}
onChange={setOrgName} onChange={setProjectSlug}
placeholder="My Organization" placeholder="my-project"
required
/>
<AdminFormInput
label="Project Name"
value={projectName}
onChange={setProjectName}
placeholder="My Project"
required required
/> />
<AdminButton type="submit" disabled={loading}> <AdminButton type="submit" disabled={loading}>

View File

@ -1,6 +1,5 @@
'use server'; 'use server';
import { getOrCreateOrgAndProject } from './orgProjectActions';
import { listApiKeys as listApiKeysQuery } from '../db/queries/apiKeys'; import { listApiKeys as listApiKeysQuery } from '../db/queries/apiKeys';
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'; 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( export async function createProjectApiKey(
masterKey: string, masterKey: string,
email: string, orgSlug: string,
orgName: string, projectSlug: string
projectName: string
): Promise<{ success: boolean; apiKey?: string; error?: string }> { ): Promise<{ success: boolean; apiKey?: string; error?: string }> {
try { try {
// First, ensure organization and project exist in DB // Call API service to create the project key (API auto-creates org/project)
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`, { const response = await fetch(`${API_BASE_URL}/api/admin/keys`, {
method: 'POST', method: 'POST',
headers: { headers: {
@ -68,9 +63,9 @@ export async function createProjectApiKey(
}, },
body: JSON.stringify({ body: JSON.stringify({
type: 'project', type: 'project',
projectId: project.id, organizationSlug: orgSlug,
organizationId: organization.id, projectSlug: projectSlug,
name: `${orgName} - ${projectName}`, name: `${orgSlug} - ${projectSlug}`,
}), }),
}); });

View File

@ -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 $$;

View File

@ -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": {}
}
}

View File

@ -1,5 +1,13 @@
{ {
"version": "7", "version": "7",
"dialect": "postgresql", "dialect": "postgresql",
"entries": [] "entries": [
{
"idx": 0,
"version": "7",
"when": 1759661399219,
"tag": "0000_curious_wolfsbane",
"breakpoints": true
}
]
} }

View File

@ -5,6 +5,7 @@ export const organizations = pgTable('organizations', {
// Organization details // Organization details
name: text('name').notNull(), name: text('name').notNull(),
slug: text('slug').notNull().unique(), // URL-friendly identifier for storage paths
email: text('email').notNull().unique(), email: text('email').notNull().unique(),
// Timestamps // Timestamps

View File

@ -6,14 +6,15 @@ export const projects = pgTable('projects', {
// Project details // Project details
name: text('name').notNull(), name: text('name').notNull(),
slug: text('slug').notNull(), // URL-friendly identifier for storage paths
organizationId: uuid('organization_id').notNull().references(() => organizations.id, { onDelete: 'cascade' }), organizationId: uuid('organization_id').notNull().references(() => organizations.id, { onDelete: 'cascade' }),
// Timestamps // Timestamps
createdAt: timestamp('created_at').notNull().defaultNow(), createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow().$onUpdate(() => new Date()), updatedAt: timestamp('updated_at').notNull().defaultNow().$onUpdate(() => new Date()),
}, (table) => ({ }, (table) => ({
// Unique constraint: one project name per organization // Unique constraint: one project slug per organization
uniqueOrgProject: unique().on(table.organizationId, table.name), uniqueOrgProjectSlug: unique().on(table.organizationId, table.slug),
})); }));
export type Project = typeof projects.$inferSelect; export type Project = typeof projects.$inferSelect;