diff --git a/.gitignore b/.gitignore index da7efcf..e48fd8a 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ out/ next-env.d.ts test-results/ playwright-report/ +public/uploads/ diff --git a/app/admin/page.tsx b/app/admin/page.tsx index 7de8598..192650a 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -404,6 +404,7 @@ export default function AdminPage() { {showRelForm && (
setShowRelForm(false)} /> diff --git a/app/api/upload/route.ts b/app/api/upload/route.ts index 24da176..5d1fb5a 100644 --- a/app/api/upload/route.ts +++ b/app/api/upload/route.ts @@ -1,7 +1,8 @@ import { NextRequest, NextResponse } from 'next/server'; -import { Storage } from '@google-cloud/storage'; import sharp from 'sharp'; import { getCurrentUser } from '@/lib/auth'; +import path from 'path'; +import fs from 'fs/promises'; const ALLOWED_TYPES = new Set([ 'image/jpeg', @@ -11,21 +12,33 @@ const ALLOWED_TYPES = new Set([ ]); const MAX_SIZE = 5 * 1024 * 1024; // 5 MB -const storage = new Storage({ - projectId: process.env.GCS_PROJECT_ID, -}); +const useGCS = !!process.env.GCS_BUCKET_NAME; -function getBucket() { - const bucketName = process.env.GCS_BUCKET_NAME; - if (!bucketName) { - throw new Error('GCS_BUCKET_NAME environment variable is not set'); - } - return storage.bucket(bucketName); +async function uploadToGCS(resized: Buffer, filename: string): Promise { + const { Storage } = await import('@google-cloud/storage'); + const storage = new Storage({ projectId: process.env.GCS_PROJECT_ID }); + const bucket = storage.bucket(process.env.GCS_BUCKET_NAME!); + const blob = bucket.file(filename); + await blob.save(resized, { + contentType: 'image/webp', + metadata: { + cacheControl: 'public, max-age=31536000, immutable', + }, + }); + return `https://storage.googleapis.com/${bucket.name}/${filename}`; +} + +async function uploadToLocal(resized: Buffer, filename: string): Promise { + const uploadDir = path.join(process.cwd(), 'public', 'uploads', 'entities'); + await fs.mkdir(uploadDir, { recursive: true }); + const filePath = path.join(uploadDir, path.basename(filename)); + await fs.writeFile(filePath, resized); + return `/uploads/entities/${path.basename(filename)}`; } export async function POST(request: NextRequest) { const user = await getCurrentUser(); - if (!user?.is_admin) { + if (!user) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } @@ -68,18 +81,11 @@ export async function POST(request: NextRequest) { const random = Math.random().toString(36).slice(2, 10); const filename = `entities/${timestamp}-${random}.webp`; - // Upload to GCS - const bucket = getBucket(); - const blob = bucket.file(filename); - await blob.save(resized, { - contentType: 'image/webp', - metadata: { - cacheControl: 'public, max-age=31536000, immutable', - }, - }); + // Upload to GCS in production, local filesystem in development + const url = useGCS + ? await uploadToGCS(resized, filename) + : await uploadToLocal(resized, filename); - // Public access is managed at the bucket level (uniform bucket-level access) - const url = `https://storage.googleapis.com/${bucket.name}/${filename}`; return NextResponse.json({ url }); } catch (err: unknown) { console.error('Upload error:', err); diff --git a/db/migrations/012_add_created_rel_type.sql b/db/migrations/012_add_created_rel_type.sql new file mode 100644 index 0000000..5efb139 --- /dev/null +++ b/db/migrations/012_add_created_rel_type.sql @@ -0,0 +1,4 @@ +-- Add 'created' relationship type: person/org/project → project +INSERT INTO rel_types (key, forward_label, reverse_label, source_types, target_types) +VALUES ('created', 'Created', 'Created by', '{person,organization,project}', '{project}') +ON CONFLICT (key) DO NOTHING; diff --git a/db/seed.sql b/db/seed.sql index 4d6495b..5d3de57 100644 --- a/db/seed.sql +++ b/db/seed.sql @@ -11,7 +11,8 @@ INSERT INTO rel_types (key, forward_label, reverse_label, source_types, target_t ('contributes_to', 'Contributes to', 'Contributor', '{person}', '{project}'), ('hosted_at', 'Hosted at', 'Hosts', '{event}', '{venue}'), ('tagged_with', 'Tagged with', 'Tags', '{person,organization,event,venue,project}', '{topic}'), - ('spoke_at', 'Spoke at', 'Speaker', '{person}', '{event}'); + ('spoke_at', 'Spoke at', 'Speaker', '{person}', '{event}'), + ('created', 'Created', 'Created by', '{person,organization,project}', '{project}'); DO $$ DECLARE @@ -376,10 +377,16 @@ BEGIN -- Project contributions INSERT INTO relationships (source_id, target_id, rel_type, label) VALUES - (v_scott, v_jira, 'contributes_to', 'Creator'), - (v_mike, v_jira, 'contributes_to', 'Creator'), - (v_melanie, v_canva_tool, 'contributes_to', 'Creator'), - (v_nicholas, v_envato_market, 'contributes_to', 'Creator'); + (v_scott, v_jira, 'contributes_to', null), + (v_mike, v_jira, 'contributes_to', null), + (v_melanie, v_canva_tool, 'contributes_to', null), + (v_nicholas, v_envato_market, 'contributes_to', null); + + -- Project creation (org created project) + INSERT INTO relationships (source_id, target_id, rel_type, label) VALUES + (v_atlassian, v_jira, 'created', null), + (v_canva, v_canva_tool, 'created', null), + (v_envato, v_envato_market, 'created', null); -- Topic tags INSERT INTO relationships (source_id, target_id, rel_type, label) VALUES diff --git a/lib/config/permissions.ts b/lib/config/permissions.ts index d3e5c2c..a55d462 100644 --- a/lib/config/permissions.ts +++ b/lib/config/permissions.ts @@ -28,7 +28,7 @@ export const ADMIN_ONLY_ENTITY_TYPES: EntityType[] = ['person', 'topic']; // creation via AUTO_RELATIONSHIP_MAP in schema.ts — that's safe because // the user just created the entity. -export const EDIT_GRANTING_REL_TYPES: string[] = ['founded', 'organized', 'manages']; +export const EDIT_GRANTING_REL_TYPES: string[] = ['founded', 'organized', 'manages', 'created']; // Relationship types that only admins can create via the API. // Currently identical to EDIT_GRANTING_REL_TYPES. Split into a separate diff --git a/lib/config/schema.ts b/lib/config/schema.ts index d3faff8..950a1ae 100644 --- a/lib/config/schema.ts +++ b/lib/config/schema.ts @@ -57,6 +57,7 @@ export const REL_TYPE_DEFINITIONS: RelTypeDefinition[] = [ { key: 'hosted_at', forwardLabel: 'Hosted at', reverseLabel: 'Hosts', sourceTypes: ['event'], targetTypes: ['venue'] }, { key: 'tagged_with', forwardLabel: 'Tagged with', reverseLabel: 'Tags', sourceTypes: ['person', 'organization', 'event', 'venue', 'project'], targetTypes: ['topic'] }, { key: 'spoke_at', forwardLabel: 'Spoke at', reverseLabel: 'Speaker', sourceTypes: ['person'], targetTypes: ['event'] }, + { key: 'created', forwardLabel: 'Created', reverseLabel: 'Created by', sourceTypes: ['person', 'organization', 'project'], targetTypes: ['project'] }, ]; /** All valid relationship type keys */