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 */