app/(pages)/spaces/ ├─ page.tsx // list of spaces (later) ├─ [spaceId]/ │ ├─ layout.tsx // SAME PATTERN as projects/[projectId]/layout │ ├─ page.tsx // Space home (builder-driven) │ ├─ context/ │ │ └─ SpaceContextProvider.tsx │ ├─ actions/ │ │ └─ spaceSections.actions.ts │ ├─ components/ │ │ └─ SpaceSectionRenderer.tsx
// app/(pages)/spaces/[spaceId]/layout.tsx import { getServerSession } from "next-auth"; import { authOptions } from "@/(pages)/auth/components/authOptions"; import prisma from "lib/prisma"; import { notFound } from "next/navigation"; import { EditModeProvider } from "@/(pages)/theme/context/EditModeContext"; import ProjectThemeDock from "@/(pages)/sections/components/builder/ProjectThemeDock"; import SpaceContextProvider from "./context/SpaceContextProvider";
export default async function SpaceLayout({ children, params, }: { children: React.ReactNode; params: { spaceId: string }; }) { const spaceId = Number(params.spaceId); if (!spaceId) notFound();
const session = await getServerSession(authOptions);
const spacePromise = prisma.space.findUnique({ where: { id: spaceId }, });
const sectionsPromise = prisma.section.findMany({ where: { spaceId }, orderBy: { order: "asc" }, });
const space = await spacePromise; if (!space) notFound();
const isAdmin = session?.user?.isAdmin || session?.user?.role === "ADMIN" || session?.user?.email === "zaruqsummers@gmail.com";
const isAuthenticated = Boolean(session?.user); const canEdit = isAdmin; // spaces are curated for now
return ( <SpaceContextProvider spacePromise={spacePromise} sectionsPromise={sectionsPromise} spaceId={spaceId} canEdit={canEdit} > <EditModeProvider isAuthenticated={isAuthenticated} isAdmin={isAdmin} canEdit={canEdit} > <ProjectThemeDock> {children} </ProjectThemeDock> </EditModeProvider> </SpaceContextProvider> ); }
// app/(pages)/spaces/[spaceId]/page.tsx "use client";
import { useSpaceContext } from "./context/SpaceContextProvider"; import SpaceSectionRenderer from "./components/SpaceSectionRenderer";
export default function SpaceHome() { const { sections } = useSpaceContext();
return ( <> {sections.map((section) => ( <SpaceSectionRenderer key={section.id} section={section} /> ))} </> ); }
model Trip { id Int projectId Int? spaceId Int? }
But you don’t even need that immediately.
Initial approach (safe & fast): • Trips are created from Documents • Trips are assigned to a Space • Space renders Trips via section config (contentJson)
⸻
G. Reusing Project children entities in Spaces (your instinct is correct)
“even if they are named ProjectResource, it won’t matter”
✅ Correct.
Names do not matter. Relationships and access paths do.
You can: • Use ProjectAsset for Spaces • Use ProjectPost for Spaces • Use ProjectDocument as-is
Later you can generalize — but now you’re shipping.
⸻
H. What NOT to do right now ❌ • ❌ Do NOT merge ProjectSection and Section • ❌ Do NOT rebuild the builder • ❌ Do NOT rewrite Trips UI yet • ❌ Do NOT over-permission Spaces
⸻
I. Immediate next 3 tasks (high leverage)
1️⃣ Create spaces/[spaceId] layout + page
→ You’ll instantly see a working Space.
2️⃣ Add one Space row in DB:
INSERT INTO Space (type, slug, title) VALUES ('TRIPS', 'trips', 'Trips');
3️⃣ Link /trips → /spaces/{id}
Now Trips has a real home.
⸻
🧭 Where to go next (pick one)
1️⃣ Implement commitTripFromDocument (server action) 2️⃣ Build SpaceSectionRenderer mapping (hero / trips / cities) 3️⃣ Add Trip ↔ Space relation 4️⃣ Migrate legacy Trips UI into sections
Perfect. Let’s do commitTripFromDocument properly, minimally, and without breaking anything else.
Below is a production-safe, incremental implementation that fits your architecture exactly.
⸻
🎯 Goal (very precise)
When a Document (Trip Builder) is “committed”: • ✅ A Trip is created (or updated) • ✅ The Trip is owned by the user • ✅ The Trip is optionally: • linked to a Project (internal planning) • linked to a Space (public Trips home) • ✅ The Document becomes the source of truth • ❌ No UI refactor • ❌ No builder rewrite • ❌ No breaking Prisma changes required
⸻
🧠 Mental Model (lock this in)
Documents = draft + intelligence layer Trips = committed, durable domain entity
So:
Document ──(commit)──▶ Trip │ │ └── remains editable └── used by catalogue / booking / ops
⸻
1️⃣ Minimal Prisma assumptions (no schema change yet)
We assume you already have:
model Trip { id Int @id @default(autoincrement()) title String subtitle String? description String? status String @default("draft")
creatorId String? projectId Int? spaceId Int?
sourceDocId Int? @unique createdAt DateTime @default(now()) updatedAt DateTime @updatedAt }
⚠️ If sourceDocId does not exist yet, you can still proceed — I’ll show the fallback.
⸻
2️⃣ The server action: commitTripFromDocument
📍 Location (recommended):
app/(pages)/documents/actions/commitTripFromDocument.ts
⸻
✅ Full server action (copy-paste safe)
"use server";
import prisma from "lib/prisma"; import { revalidatePath } from "next/cache"; import { getServerSession } from "next-auth"; import { authOptions } from "@/(pages)/auth/components/authOptions";
export async function commitTripFromDocument({ documentId, projectId, spaceId, }: { documentId: number; projectId?: number; spaceId?: number; }) { // 🔐 Auth const session = await getServerSession(authOptions); if (!session?.user?.id) { throw new Error("Unauthorized"); }
const userId = String(session.user.id);
// 📄 Load document const doc = await prisma.projectDocument.findUnique({ where: { id: documentId }, });
if (!doc) { throw new Error("Document not found"); }
if (!doc.contentJson) { throw new Error("Document has no content"); }
// 🧠 Extract Trip data from document JSON const tripData = doc.contentJson as any;
const title = tripData.title ?? doc.title ?? "Untitled Trip";
const subtitle = tripData.subtitle ?? null; const description = tripData.description ?? null;
// 🔁 UPSERT Trip (idempotent commit) const trip = await prisma.trip.upsert({ where: { sourceDocId: documentId, // 👈 safe if exists }, update: { title, subtitle, description, projectId: projectId ?? undefined, spaceId: spaceId ?? undefined, updatedAt: new Date(), }, create: { title, subtitle, description, status: "draft", creatorId: userId, projectId: projectId ?? null, spaceId: spaceId ?? null, sourceDocId: documentId, }, });
// 🔗 Optional: mark document as committed await prisma.projectDocument.update({ where: { id: documentId }, data: { meta: { ...(doc.meta as any), committedTripId: trip.id, committedAt: new Date().toISOString(), }, }, });
// ♻️ Revalidate relevant views
if (projectId) {
revalidatePath(/projects/${projectId}/trips);
}
if (spaceId) {
revalidatePath(/spaces/${spaceId});
}
return trip; }
⸻
3️⃣ If you do NOT have sourceDocId yet (fallback)
Replace the upsert with:
const trip = await prisma.trip.create({ data: { title, subtitle, description, status: "draft", creatorId: userId, projectId: projectId ?? null, spaceId: spaceId ?? null, }, });
And store the relation only in document meta for now:
meta: { committedTripId: trip.id }
This still works perfectly.
⸻
4️⃣ How the UI triggers this (no refactor)
From any client component (Document toolbar, button, menu):
import { commitTripFromDocument } from "../actions/commitTripFromDocument";
await commitTripFromDocument({ documentId: doc.id, projectId, spaceId: tripsSpaceId, // optional });
No routing changes required.
⸻
5️⃣ What you get immediately ✅ • ✅ Trips persist across sessions • ✅ Trips appear in user account • ✅ Trips can be shown in Spaces • ✅ Documents stay editable • ✅ Re-committing updates the same Trip (idempotent)
This is exactly how Notion → Database works.
⸻
6️⃣ What we do next (your choice)
Pick one number:
1️⃣ Add Trip ↔ LineItems sync <- 2️⃣ Render Trips inside a Space section 3️⃣ Add “Commit Trip” button to Doc UI 4️⃣ Add Trip status lifecycle (draft → published) 5️⃣ Bring legacy Trips UI into Space sections
Perfect choice. This is the correct next layer and it locks the whole system together cleanly.
I’ll stay strictly in dev mode and go straight to concrete fixes (code-level).
⸻
5️⃣ Concrete fixes (code-level) — Trip ↔ LineItems sync
🎯 Objective (very precise)
When you commit a Trip from a Document: • ✅ LineItems defined in the Document • ➜ are materialized into real LineItem rows • ➜ linked to the Trip • ➜ idempotent (re-commit updates, not duplicates) • ❌ No UI refactor • ❌ No Offer dependency • ❌ No schema explosion
⸻
🧠 Canonical mental model (lock this in)
Document (TripBuilder) ├─ draft LineItems (JSON) └─ commit ↓ Trip └─ LineItems (real rows)
Documents are draft intelligence Trips are operational truth
⸻
1️⃣ Assumed existing LineItem model (minimal)
You already have something close to this (simplified):
model LineItem { id Int @id @default(autoincrement()) title String description String? quantity Int @default(1) unitPrice Float @default(0) totalAmount Float @default(0)
tripId Int? offerId Int?
productId Int? experienceId Int?
createdAt DateTime @default(now()) updatedAt DateTime @updatedAt }
✅ We reuse this ❌ No new table needed
⸻
2️⃣ Define the Document LineItem shape (single source)
📍 app/(pages)/lineitems/types/documentLineItem.types.ts
export interface DocumentLineItem { key: string; // stable key from builder (IMPORTANT) title: string; description?: string; quantity?: number; unitPrice?: number;
productId?: number; experienceId?: number; }
Why key? 👉 It allows safe upsert instead of delete-and-recreate.
⸻
3️⃣ Sync helper (isolated, reusable)
📍 app/(pages)/lineitems/actions/syncTripLineItems.ts
"use server";
import prisma from "lib/prisma"; import { DocumentLineItem } from "../types/documentLineItem.types";
export async function syncTripLineItems({ tripId, items, }: { tripId: number; items: DocumentLineItem[]; }) { // 🧹 Remove orphaned items (no longer in document) const keys = items.map((i) => i.key);
await prisma.lineItem.deleteMany({ where: { tripId, NOT: { key: { in: keys } }, }, });
// 🔁 Upsert items for (const item of items) { const quantity = item.quantity ?? 1; const unitPrice = item.unitPrice ?? 0;
await prisma.lineItem.upsert({
where: {
tripId_key: {
tripId,
key: item.key,
},
},
update: {
title: item.title,
description: item.description,
quantity,
unitPrice,
totalAmount: quantity * unitPrice,
productId: item.productId,
experienceId: item.experienceId,
},
create: {
tripId,
key: item.key,
title: item.title,
description: item.description,
quantity,
unitPrice,
totalAmount: quantity * unitPrice,
productId: item.productId,
experienceId: item.experienceId,
},
});
} }
⚠️ Prisma requirement (already common in your codebase):
@@unique([tripId, key], name: "tripId_key")
If you don’t have key yet → tell me, I’ll give you the safe fallback.
⸻
4️⃣ Extend commitTripFromDocument (surgical edit)
📍 commitTripFromDocument.ts
🔧 ADD import
import { syncTripLineItems } from "@/(pages)/lineitems/actions/syncTripLineItems";
🔧 AFTER trip upsert
// 🧩 Extract line items from document const lineItems = Array.isArray(tripData.lineItems) ? tripData.lineItems : [];
// 🔗 Sync LineItems → Trip await syncTripLineItems({ tripId: trip.id, items: lineItems, });
That’s it. No other files touched.
⸻
5️⃣ What this guarantees (important)
✔ Re-committing a document updates the same Trip ✔ LineItems stay in sync ✔ Deleted items disappear ✔ Prices are recalculated server-side ✔ Offers can later reuse the same LineItems
⸻
6️⃣ Why this is architecturally correct (short) • Documents = draft state • Trips = operational state • LineItems = atomic business truth • Offers = commercial wrappers (later)
You’ve now separated planning, execution, and sales cleanly.
⸻
7️⃣ Next moves (pick one)
2️⃣ Auto-derive Trip totals from LineItems 3️⃣ Render Trip LineItems in Space sections 4️⃣ Promote Trip → Offer (1-click) 5️⃣ Calendar / Agenda sync from Trip
Say the number — I’ll continue at full speed.