diff --git a/.env.example b/.env.example index 53ee8b9..ac25192 100644 --- a/.env.example +++ b/.env.example @@ -12,3 +12,11 @@ SUPABASE_SERVICE_ROLE_KEY= MUMBL_TOKEN_HASH_SECRET= MUMBL_SIDE_QUEST_ENCRYPTION_KEY= CRON_SECRET= + +# Server-only AI settings for opt-in dump field-note drafts. +OPENAI_API_KEY= +OPENAI_MODEL_FIELD_NOTE=gpt-5.4-nano +OPENAI_MAX_DAILY_DRAFTS=20 + +# Server-only memory graph settings for private dump map sync/search. +SUPERMEMORY_API_KEY= diff --git a/AGENTS.md b/AGENTS.md index 6aabdaf..c7861d7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,6 +34,7 @@ When deciding whether to add a feature, ask whether it makes Mumbl feel more lik Work like a senior engineer: - Inspect existing patterns before adding new ones. +- Use TypeScript for new code going forward. When touching existing JavaScript files, opportunistically migrate them to TypeScript only when it is low-risk, keeps the change scoped, and does not break existing behavior. Prefer explicit local types for API payloads, server responses, and component props as files are migrated. - Keep changes tightly scoped to the request. - Prefer simple modules and explicit data flow over clever abstractions. - Add abstractions only when they remove real duplication or match an existing local pattern. diff --git a/app/[publicHandle]/page.jsx b/app/[publicHandle]/page.jsx new file mode 100644 index 0000000..7116205 --- /dev/null +++ b/app/[publicHandle]/page.jsx @@ -0,0 +1,14 @@ +import { notFound } from "next/navigation"; +import PublicProfilePage, { getPublicProfileMetadata } from "../../src/components/PublicProfilePage"; + +export async function generateMetadata({ params }) { + const { publicHandle } = await params; + if (!publicHandle?.startsWith("@")) return {}; + return getPublicProfileMetadata(publicHandle); +} + +export default async function RootPublicProfilePage({ params }) { + const { publicHandle } = await params; + if (!publicHandle?.startsWith("@")) notFound(); + return ; +} diff --git a/app/api/dumps/[dumpId]/route.js b/app/api/dumps/[dumpId]/route.js new file mode 100644 index 0000000..2b356cf --- /dev/null +++ b/app/api/dumps/[dumpId]/route.js @@ -0,0 +1,62 @@ +import { badRequest, notFound, ok, serverError } from "../../../../src/server/http"; +import { hashToken } from "../../../../src/server/hash"; +import { makeLocalReflection, serializeDump } from "../../../../src/server/dumps"; +import { getSupabaseAdmin } from "../../../../src/server/supabase"; +import { cleanString } from "../../../../src/server/validation"; + +export async function PATCH(request, { params }) { + try { + const { dumpId } = await params; + const body = await request.json(); + const sessionToken = cleanString(body.sessionToken, 256); + const content = cleanString(body.content, 4000); + const wantsReflection = body.wantsReflection === true; + + if (!dumpId) return badRequest("dump id is required"); + if (!sessionToken) return badRequest("session token is required"); + if (!content) return badRequest("dump content is required"); + + const supabase = getSupabaseAdmin(); + const { data: dump, error } = await supabase + .from("dumps") + .update({ + content, + ai_reflection: wantsReflection ? makeLocalReflection(content) : null, + updated_at: new Date().toISOString(), + }) + .eq("id", dumpId) + .eq("session_token_hash", hashToken(sessionToken)) + .select("*") + .single(); + if (error?.code === "PGRST116") return notFound("dump not found"); + if (error) throw error; + + return ok({ dump: serializeDump(dump) }); + } catch (error) { + return serverError(error); + } +} + +export async function DELETE(request, { params }) { + try { + const { dumpId } = await params; + const body = await request.json(); + const sessionToken = cleanString(body.sessionToken, 256); + + if (!dumpId) return badRequest("dump id is required"); + if (!sessionToken) return badRequest("session token is required"); + + const supabase = getSupabaseAdmin(); + const { error, count } = await supabase + .from("dumps") + .delete({ count: "exact" }) + .eq("id", dumpId) + .eq("session_token_hash", hashToken(sessionToken)); + if (error) throw error; + if (!count) return notFound("dump not found"); + + return ok({ deleted: true }); + } catch (error) { + return serverError(error); + } +} diff --git a/app/api/dumps/[dumpId]/team/route.js b/app/api/dumps/[dumpId]/team/route.js new file mode 100644 index 0000000..0ab558b --- /dev/null +++ b/app/api/dumps/[dumpId]/team/route.js @@ -0,0 +1,9 @@ +import { badRequest, serverError } from "../../../../../src/server/http"; + +export async function POST(request, { params }) { + try { + return badRequest("raw dumps stay private. draft and publish a field note instead."); + } catch (error) { + return serverError(error); + } +} diff --git a/app/api/dumps/field-notes/[fieldNoteId]/public/route.js b/app/api/dumps/field-notes/[fieldNoteId]/public/route.js new file mode 100644 index 0000000..1220136 --- /dev/null +++ b/app/api/dumps/field-notes/[fieldNoteId]/public/route.js @@ -0,0 +1,92 @@ +import { badRequest, notFound, ok, serverError } from "../../../../../../src/server/http"; +import { hashToken } from "../../../../../../src/server/hash"; +import { serializeFieldNote } from "../../../../../../src/server/dumps"; +import { getSupabaseAdmin } from "../../../../../../src/server/supabase"; +import { cleanString } from "../../../../../../src/server/validation"; +import { normalizeHandle, serializePublicProfile } from "../../../../../../src/server/publicProfiles"; + +export async function PATCH(request, { params }) { + try { + const { fieldNoteId } = await params; + const body = await request.json(); + const sessionToken = cleanString(body.sessionToken, 256); + const isPublic = body.isPublic === true; + const handle = normalizeHandle(body.handle); + + if (!fieldNoteId) return badRequest("field note id is required"); + if (!sessionToken) return badRequest("session token is required"); + if (isPublic && !handle) return badRequest("choose a public handle first"); + + const supabase = getSupabaseAdmin(); + const sessionTokenHash = hashToken(sessionToken); + const { data: fieldNote, error: noteError } = await supabase + .from("field_notes") + .select("*") + .eq("id", fieldNoteId) + .eq("session_token_hash", sessionTokenHash) + .single(); + if (noteError?.code === "PGRST116") return notFound("field note not found"); + if (isMissingColumnError(noteError) || isMissingTableError(noteError)) return serverError(missingPublicProfileMigrationError()); + if (noteError) throw noteError; + if (isPublic && !fieldNote.is_published) return badRequest("publish to team reads before putting this on your profile"); + + let profile = null; + if (isPublic) { + const { data: profileRow, error: profileError } = await supabase + .from("public_profiles") + .select("*") + .eq("session_token_hash", sessionTokenHash) + .eq("handle", handle) + .single(); + if (profileError?.code === "PGRST116") return badRequest("create that public handle first"); + if (isMissingTableError(profileError)) return serverError(missingPublicProfileMigrationError()); + if (profileError) throw profileError; + profile = profileRow; + } + + const updates = isPublic + ? { + is_public: true, + public_profile_id: profile.id, + public_published_at: fieldNote.public_published_at || new Date().toISOString(), + } + : { + is_public: false, + public_profile_id: null, + public_published_at: null, + }; + + const { data: updatedNote, error: updateError } = await supabase + .from("field_notes") + .update(updates) + .eq("id", fieldNote.id) + .eq("session_token_hash", sessionTokenHash) + .select("*") + .single(); + if (isMissingColumnError(updateError)) return serverError(missingPublicProfileMigrationError()); + if (updateError) throw updateError; + + return ok({ + fieldNote: serializeFieldNote(updatedNote), + profile: profile ? serializePublicProfile(profile) : null, + }); + } catch (error) { + return serverError(error); + } +} + +function isMissingTableError(error) { + const message = `${error?.message || ""} ${error?.details || ""} ${error?.hint || ""}`.toLowerCase(); + return error?.code === "42P01" || error?.code === "PGRST205" || message.includes("could not find the table"); +} + +function isMissingColumnError(error) { + const message = `${error?.message || ""} ${error?.details || ""} ${error?.hint || ""}`.toLowerCase(); + return error?.code === "42703" || error?.code === "PGRST204" || (message.includes("could not find") && message.includes("column")); +} + +function missingPublicProfileMigrationError() { + const error = new Error("Public profile migration is not applied yet. Run supabase/migrations/0015_public_profiles.sql."); + error.status = 503; + return error; +} diff --git a/app/api/dumps/field-notes/[fieldNoteId]/publish/route.js b/app/api/dumps/field-notes/[fieldNoteId]/publish/route.js new file mode 100644 index 0000000..4ac948d --- /dev/null +++ b/app/api/dumps/field-notes/[fieldNoteId]/publish/route.js @@ -0,0 +1,82 @@ +import { badRequest, notFound, ok, serverError } from "../../../../../../src/server/http"; +import { enforceRateLimit } from "../../../../../../src/server/rateLimit"; +import { hashToken } from "../../../../../../src/server/hash"; +import { serializeFieldNote } from "../../../../../../src/server/dumps"; +import { getSupabaseAdmin } from "../../../../../../src/server/supabase"; +import { cleanString } from "../../../../../../src/server/validation"; + +export async function POST(request, { params }) { + try { + const { fieldNoteId } = await params; + const body = await request.json(); + const slug = cleanString(body.slug, 64); + const sessionToken = cleanString(body.sessionToken, 256); + const title = cleanString(body.title, 120); + const content = cleanString(body.content, 4000); + const isAnonymous = body.isAnonymous !== false; + const displayName = isAnonymous ? null : cleanString(body.displayName, 48) || "someone brave"; + + if (!fieldNoteId) return badRequest("field note id is required"); + if (!slug) return badRequest("space slug is required"); + if (!sessionToken) return badRequest("session token is required"); + if (!title) return badRequest("field note title is required"); + if (!content) return badRequest("field note content is required"); + + const supabase = getSupabaseAdmin(); + await enforceRateLimit({ supabase, action: "post", sessionToken }); + const sessionTokenHash = hashToken(sessionToken); + + const [{ data: fieldNote, error: noteError }, { data: space, error: spaceError }] = await Promise.all([ + supabase.from("field_notes").select("*").eq("id", fieldNoteId).eq("session_token_hash", sessionTokenHash).single(), + supabase.from("spaces").select("id").eq("slug", slug).single(), + ]); + if (noteError?.code === "PGRST116") return notFound("field note not found"); + if (spaceError?.code === "PGRST116") return notFound("space not found"); + if (noteError) throw noteError; + if (spaceError) throw spaceError; + if (fieldNote.is_published) return badRequest("field note is already published"); + + const { data: post, error: postError } = await supabase + .from("posts") + .insert({ + space_id: space.id, + type: "field_note", + field_note_title: title, + content, + is_anonymous: isAnonymous, + display_name: displayName, + }) + .select() + .single(); + if (postError) throw postError; + + const { data: updatedNote, error: updateError } = await supabase + .from("field_notes") + .update({ + team_room_id: space.id, + title, + content, + is_published: true, + published_post_id: post.id, + published_at: new Date().toISOString(), + }) + .eq("id", fieldNote.id) + .eq("session_token_hash", sessionTokenHash) + .select("*") + .single(); + if (updateError) throw updateError; + + if (isAnonymous) { + await supabase.from("anon_audit").insert({ + post_id: post.id, + session_token_hash: sessionTokenHash, + }); + } + + await supabase.from("spaces").update({ first_post_done: true }).eq("id", space.id); + + return ok({ fieldNote: serializeFieldNote(updatedNote), post }); + } catch (error) { + return serverError(error); + } +} diff --git a/app/api/dumps/field-notes/[fieldNoteId]/route.js b/app/api/dumps/field-notes/[fieldNoteId]/route.js new file mode 100644 index 0000000..09c4300 --- /dev/null +++ b/app/api/dumps/field-notes/[fieldNoteId]/route.js @@ -0,0 +1,91 @@ +import { badRequest, notFound, ok, serverError } from "../../../../../src/server/http"; +import { hashToken } from "../../../../../src/server/hash"; +import { serializeFieldNote } from "../../../../../src/server/dumps"; +import { getSupabaseAdmin } from "../../../../../src/server/supabase"; +import { cleanString } from "../../../../../src/server/validation"; + +export async function PATCH(request, { params }) { + try { + const { fieldNoteId } = await params; + const body = await request.json(); + const sessionToken = cleanString(body.sessionToken, 256); + const title = cleanString(body.title, 120); + const content = cleanString(body.content, 4000); + + if (!fieldNoteId) return badRequest("field note id is required"); + if (!sessionToken) return badRequest("session token is required"); + if (!title) return badRequest("field note title is required"); + if (!content) return badRequest("field note content is required"); + + const supabase = getSupabaseAdmin(); + const sessionTokenHash = hashToken(sessionToken); + const { data: fieldNote, error: noteError } = await supabase + .from("field_notes") + .select("*") + .eq("id", fieldNoteId) + .eq("session_token_hash", sessionTokenHash) + .single(); + if (noteError?.code === "PGRST116") return notFound("field note not found"); + if (noteError) throw noteError; + + const updates = { title, content }; + const { data: updatedNote, error: updateError } = await supabase + .from("field_notes") + .update(updates) + .eq("id", fieldNote.id) + .eq("session_token_hash", sessionTokenHash) + .select("*") + .single(); + if (updateError) throw updateError; + + if (fieldNote.is_published && fieldNote.published_post_id) { + const { error: postError } = await supabase + .from("posts") + .update({ field_note_title: title, content }) + .eq("id", fieldNote.published_post_id); + if (postError) throw postError; + } + + return ok({ fieldNote: serializeFieldNote(updatedNote) }); + } catch (error) { + return serverError(error); + } +} + +export async function DELETE(request, { params }) { + try { + const { fieldNoteId } = await params; + const body = await request.json(); + const sessionToken = cleanString(body.sessionToken, 256); + + if (!fieldNoteId) return badRequest("field note id is required"); + if (!sessionToken) return badRequest("session token is required"); + + const supabase = getSupabaseAdmin(); + const sessionTokenHash = hashToken(sessionToken); + const { data: fieldNote, error: noteError } = await supabase + .from("field_notes") + .select("*") + .eq("id", fieldNoteId) + .eq("session_token_hash", sessionTokenHash) + .single(); + if (noteError?.code === "PGRST116") return notFound("field note not found"); + if (noteError) throw noteError; + + if (fieldNote.published_post_id) { + const { error: postError } = await supabase.from("posts").delete().eq("id", fieldNote.published_post_id); + if (postError) throw postError; + } + + const { error: deleteError } = await supabase + .from("field_notes") + .delete() + .eq("id", fieldNote.id) + .eq("session_token_hash", sessionTokenHash); + if (deleteError) throw deleteError; + + return ok({ deleted: true }); + } catch (error) { + return serverError(error); + } +} diff --git a/app/api/dumps/field-notes/draft/route.js b/app/api/dumps/field-notes/draft/route.js new file mode 100644 index 0000000..0a04980 --- /dev/null +++ b/app/api/dumps/field-notes/draft/route.js @@ -0,0 +1,52 @@ +import { badRequest, ok, serverError } from "../../../../../src/server/http"; +import { draftFieldNote } from "../../../../../src/server/fieldNotes"; +import { hashToken } from "../../../../../src/server/hash"; +import { enforceRateLimit } from "../../../../../src/server/rateLimit"; +import { serializeFieldNote } from "../../../../../src/server/dumps"; +import { getSupabaseAdmin } from "../../../../../src/server/supabase"; +import { cleanString } from "../../../../../src/server/validation"; + +const MAX_DUMPS_PER_DRAFT = 10; + +export async function POST(request) { + try { + const body = await request.json(); + const sessionToken = cleanString(body.sessionToken, 256); + const dumpIds = Array.isArray(body.dumpIds) ? body.dumpIds.map((id) => cleanString(id, 64)).filter(Boolean) : []; + + if (!sessionToken) return badRequest("session token is required"); + if (!dumpIds.length) return badRequest("choose at least one dump"); + if (dumpIds.length > MAX_DUMPS_PER_DRAFT) return badRequest(`choose ${MAX_DUMPS_PER_DRAFT} dumps or fewer`); + + const supabase = getSupabaseAdmin(); + await enforceRateLimit({ supabase, action: "field_note", sessionToken }); + + const sessionTokenHash = hashToken(sessionToken); + const { data: dumps, error: dumpsError } = await supabase + .from("dumps") + .select("*") + .eq("session_token_hash", sessionTokenHash) + .in("id", dumpIds); + if (dumpsError) throw dumpsError; + if (!dumps?.length) return badRequest("no matching private dumps found"); + + const orderedDumps = dumpIds.map((id) => dumps.find((dump) => dump.id === id)).filter(Boolean); + const draft = await draftFieldNote({ dumps: orderedDumps }); + + const { data: fieldNote, error: noteError } = await supabase + .from("field_notes") + .insert({ + session_token_hash: sessionTokenHash, + source_dump_ids: draft.sourceDumpIds, + title: draft.title || "field note", + content: draft.content, + }) + .select("*") + .single(); + if (noteError) throw noteError; + + return ok({ fieldNote: serializeFieldNote(fieldNote), visibilityReminder: draft.visibilityReminder }); + } catch (error) { + return serverError(error); + } +} diff --git a/app/api/dumps/map/route.ts b/app/api/dumps/map/route.ts new file mode 100644 index 0000000..d639296 --- /dev/null +++ b/app/api/dumps/map/route.ts @@ -0,0 +1,339 @@ +import { badRequest, ok, serverError } from "../../../../src/server/http"; +import { hashToken } from "../../../../src/server/hash"; +import { getSupabaseAdmin } from "../../../../src/server/supabase"; +import { cleanString } from "../../../../src/server/validation"; +import { + addFieldNoteMemory, + addPrivateDumpMemory, + type DumpSearchProbe, + isSupermemoryConfigured, + makeDumpGraph, + searchPrivateDumpMemories, + searchFieldNoteMemories, +} from "../../../../src/server/supermemory"; + +const SYNC_BATCH_SIZE = 12; +const SEARCH_PROBES: DumpSearchProbe[] = [ + { + id: "blockers", + label: "blockers and stuck loops", + tone: "clay", + query: "private work notes about blockers, stuck loops, confusion, waiting, unresolved friction, things that keep coming back", + }, + { + id: "momentum", + label: "momentum and wins", + tone: "mint", + query: "private work notes about wins, shipped work, progress, useful decisions, flow, energy, what is working", + }, + { + id: "team-process", + label: "team process signals", + tone: "gold", + query: "private work notes about meetings, standup, planning, collaboration, handoffs, team process, communication gaps", + }, + { + id: "load", + label: "load and attention", + tone: "violet", + query: "private work notes about tiredness, overload, attention, context switching, burnout, hard days, emotional load", + }, +]; + +type DumpRow = { + id: string; + content: string; + created_at: string; + supermemory_id?: string | null; + supermemory_status?: string | null; +}; + +type FieldNoteRow = { + id: string; + title: string; + content: string; + source_dump_ids: string[]; + is_published: boolean; + published_at?: string | null; + created_at: string; + supermemory_id?: string | null; + supermemory_status?: string | null; + supermemory_synced_at?: string | null; +}; + +type SupabaseAdmin = ReturnType; + +export async function GET(request: Request) { + try { + const url = new URL(request.url); + const sessionToken = cleanString(url.searchParams.get("sessionToken"), 256); + const includePrivateDumps = url.searchParams.get("includePrivateDumps") === "true"; + if (!sessionToken) return badRequest("session token is required"); + + const supabase = getSupabaseAdmin(); + const sessionTokenHash = hashToken(sessionToken); + const [{ data: dumps, error }, { data: fieldNotes, error: fieldNotesError }] = await Promise.all([ + supabase + .from("dumps") + .select("id, content, created_at, supermemory_id, supermemory_status") + .eq("session_token_hash", sessionTokenHash) + .order("created_at", { ascending: false }) + .limit(80), + supabase + .from("field_notes") + .select("*") + .eq("session_token_hash", sessionTokenHash) + .order("created_at", { ascending: false }) + .limit(50), + ]); + const hasFieldNoteSupermemoryColumns = !isMissingColumnError(fieldNotesError); + if (isMissingTableError(error) || isMissingTableError(fieldNotesError)) { + return serverError(missingDumpMigrationError()); + } + if (error) throw error; + if (fieldNotesError && hasFieldNoteSupermemoryColumns) throw fieldNotesError; + + const fallbackFieldNotes = hasFieldNoteSupermemoryColumns + ? fieldNotes || [] + : await loadFieldNotesWithoutSupermemoryColumns({ supabase, sessionTokenHash }); + + const dumpRows = dumps || []; + const fieldNoteRows = fallbackFieldNotes; + let source: "local" | "supermemory" = "local"; + let syncError = ""; + let searchResults: unknown[] = []; + + if (isSupermemoryConfigured() && hasFieldNoteSupermemoryColumns && (fieldNoteRows.length || (includePrivateDumps && dumpRows.length))) { + try { + await syncMissingFieldNotes({ supabase, fieldNotes: fieldNoteRows, sessionTokenHash }); + if (includePrivateDumps) { + await syncOptedInPrivateDumps({ supabase, dumps: dumpRows, sessionTokenHash }); + } + searchResults = ( + await Promise.all( + SEARCH_PROBES.map(async (probe) => { + const fieldNoteResults = await searchFieldNoteMemories({ + sessionTokenHash, + query: probe.query, + limit: 6, + }); + const privateDumpResults = includePrivateDumps + ? await searchPrivateDumpMemories({ + sessionTokenHash, + query: probe.query, + limit: 6, + }) + : []; + return [...fieldNoteResults, ...privateDumpResults].map((result) => ({ + ...(result && typeof result === "object" ? result : {}), + probe: { + id: probe.id, + label: probe.label, + tone: probe.tone, + }, + })); + }), + ) + ).flat(); + source = "supermemory"; + } catch (error) { + console.warn("Supermemory map sync failed", error); + syncError = error instanceof Error ? error.message : "Supermemory sync failed"; + } + } + + const memoryStatus = makeMemoryStatus({ + configured: isSupermemoryConfigured(), + hasFieldNoteSupermemoryColumns, + includePrivateDumps, + source, + syncError, + fieldNoteCount: fieldNoteRows.length, + dumpCount: dumpRows.length, + }); + + return ok({ + graph: makeDumpGraph({ dumps: dumpRows, fieldNotes: fieldNoteRows, includePrivateDumps, searchResults, source }), + supermemory: { + configured: isSupermemoryConfigured(), + status: memoryStatus.status, + label: memoryStatus.label, + detail: memoryStatus.detail, + source, + syncError: syncError ? "Memory sync is unavailable right now. Showing a lower-confidence local read." : "", + includePrivateDumps, + }, + }); + } catch (error) { + return serverError(error); + } +} + +function makeMemoryStatus({ + configured, + hasFieldNoteSupermemoryColumns, + includePrivateDumps, + source, + syncError, + fieldNoteCount, + dumpCount, +}: { + configured: boolean; + hasFieldNoteSupermemoryColumns: boolean; + includePrivateDumps: boolean; + source: "local" | "supermemory"; + syncError: string; + fieldNoteCount: number; + dumpCount: number; +}) { + if (!configured) { + return { + status: "local", + label: "local only", + detail: "Supermemory is not configured. Field notes stay local to this session view.", + }; + } + + if (!hasFieldNoteSupermemoryColumns) { + return { + status: "waiting", + label: "memory migration needed", + detail: "Apply migration 0014 to enable Supermemory sync for field notes. Showing a local read for now.", + }; + } + + if (syncError) { + return { + status: "unavailable", + label: "memory unavailable", + detail: "Supermemory could not sync right now. The page is showing a lower-confidence local read.", + }; + } + + if (source === "supermemory") { + return { + status: "active", + label: "supermemory active", + detail: includePrivateDumps + ? "Field notes and opted-in private dumps can shape this graph." + : "Field notes are shaping this graph.", + }; + } + + if (!fieldNoteCount && !(includePrivateDumps && dumpCount)) { + return { + status: "waiting", + label: "memory waiting", + detail: includePrivateDumps + ? "Write a few dumps or draft a field note to start the memory graph." + : "Draft a field note to start the memory graph.", + }; + } + + return { + status: "local", + label: "local read", + detail: "Memory sources exist, but Supermemory has not shaped this view yet.", + }; +} + +function isMissingTableError(error: unknown) { + const supabaseError = error as { code?: string; message?: string; details?: string; hint?: string } | null; + const message = `${supabaseError?.message || ""} ${supabaseError?.details || ""} ${supabaseError?.hint || ""}`.toLowerCase(); + return supabaseError?.code === "42P01" || supabaseError?.code === "PGRST205" || message.includes("could not find the table"); +} + +function isMissingColumnError(error: unknown) { + const supabaseError = error as { code?: string; message?: string; details?: string; hint?: string } | null; + const message = `${supabaseError?.message || ""} ${supabaseError?.details || ""} ${supabaseError?.hint || ""}`.toLowerCase(); + return supabaseError?.code === "PGRST204" || message.includes("could not find") && message.includes("column"); +} + +async function loadFieldNotesWithoutSupermemoryColumns({ + supabase, + sessionTokenHash, +}: { + supabase: SupabaseAdmin; + sessionTokenHash: string; +}) { + const { data, error } = await supabase + .from("field_notes") + .select("id, title, content, source_dump_ids, is_published, published_at, created_at") + .eq("session_token_hash", sessionTokenHash) + .order("created_at", { ascending: false }) + .limit(50); + if (error) throw error; + return data || []; +} + +function missingDumpMigrationError() { + const error = new Error("Dump database migration is not applied yet. Run supabase/migrations/0011_mumbl_dump.sql."); + (error as Error & { status?: number }).status = 503; + return error; +} + +async function syncMissingFieldNotes({ + supabase, + fieldNotes, + sessionTokenHash, +}: { + supabase: SupabaseAdmin; + fieldNotes: FieldNoteRow[]; + sessionTokenHash: string; +}) { + const missing = fieldNotes + .filter((fieldNote) => !fieldNote.supermemory_id || !fieldNote.supermemory_status?.startsWith("field_note_scoped:")) + .slice(0, SYNC_BATCH_SIZE); + + for (const fieldNote of missing) { + const result = await addFieldNoteMemory({ fieldNote, sessionTokenHash }); + if (!result?.id) continue; + + const { error } = await supabase + .from("field_notes") + .update({ + supermemory_id: result.id, + supermemory_status: result.status, + supermemory_synced_at: new Date().toISOString(), + }) + .eq("id", fieldNote.id) + .eq("session_token_hash", sessionTokenHash); + if (error) throw error; + + fieldNote.supermemory_id = result.id; + fieldNote.supermemory_status = result.status; + } +} + +async function syncOptedInPrivateDumps({ + supabase, + dumps, + sessionTokenHash, +}: { + supabase: SupabaseAdmin; + dumps: DumpRow[]; + sessionTokenHash: string; +}) { + const missing = dumps + .filter((dump) => !dump.supermemory_id || !dump.supermemory_status?.startsWith("private_dump_opt_in:")) + .slice(0, SYNC_BATCH_SIZE); + + for (const dump of missing) { + const result = await addPrivateDumpMemory({ dump, sessionTokenHash }); + if (!result?.id) continue; + + const { error } = await supabase + .from("dumps") + .update({ + supermemory_id: result.id, + supermemory_status: result.status, + supermemory_synced_at: new Date().toISOString(), + }) + .eq("id", dump.id) + .eq("session_token_hash", sessionTokenHash); + if (error) throw error; + + dump.supermemory_id = result.id; + dump.supermemory_status = result.status; + } +} diff --git a/app/api/dumps/route.js b/app/api/dumps/route.js new file mode 100644 index 0000000..fcbcaad --- /dev/null +++ b/app/api/dumps/route.js @@ -0,0 +1,77 @@ +import { badRequest, ok, serverError } from "../../../src/server/http"; +import { hashToken } from "../../../src/server/hash"; +import { serializeDump, serializeFieldNote, makeLocalReflection } from "../../../src/server/dumps"; +import { getSupabaseAdmin } from "../../../src/server/supabase"; +import { cleanString } from "../../../src/server/validation"; + +export async function GET(request) { + try { + const url = new URL(request.url); + const sessionToken = cleanString(url.searchParams.get("sessionToken"), 256); + if (!sessionToken) return badRequest("session token is required"); + + const supabase = getSupabaseAdmin(); + const sessionTokenHash = hashToken(sessionToken); + const [{ data: dumps, error }, { data: fieldNotes, error: fieldNotesError }] = await Promise.all([ + supabase.from("dumps").select("*").eq("session_token_hash", sessionTokenHash).order("created_at", { ascending: false }).limit(80), + supabase + .from("field_notes") + .select("*") + .eq("session_token_hash", sessionTokenHash) + .order("created_at", { ascending: false }) + .limit(20), + ]); + if (isMissingTableError(error)) { + return serverError(missingDumpMigrationError()); + } + if (error) throw error; + if (fieldNotesError && !isMissingTableError(fieldNotesError)) throw fieldNotesError; + + return ok({ dumps: (dumps || []).map(serializeDump), fieldNotes: fieldNotesError ? [] : (fieldNotes || []).map(serializeFieldNote) }); + } catch (error) { + return serverError(error); + } +} + +export async function POST(request) { + try { + const body = await request.json(); + const sessionToken = cleanString(body.sessionToken, 256); + const content = cleanString(body.content, 4000); + const wantsReflection = body.wantsReflection === true; + + if (!sessionToken) return badRequest("session token is required"); + if (!content) return badRequest("dump content is required"); + + const supabase = getSupabaseAdmin(); + const { data: dump, error } = await supabase + .from("dumps") + .insert({ + session_token_hash: hashToken(sessionToken), + content, + visibility: "private", + ai_reflection: wantsReflection ? makeLocalReflection(content) : null, + }) + .select("*") + .single(); + if (isMissingTableError(error)) { + return serverError(missingDumpMigrationError()); + } + if (error) throw error; + + return ok({ dump: serializeDump(dump) }); + } catch (error) { + return serverError(error); + } +} + +function isMissingTableError(error) { + const message = `${error?.message || ""} ${error?.details || ""} ${error?.hint || ""}`.toLowerCase(); + return error?.code === "42P01" || error?.code === "PGRST205" || message.includes("could not find the table"); +} + +function missingDumpMigrationError() { + const error = new Error("Dump database migration is not applied yet. Run supabase/migrations/0011_mumbl_dump.sql."); + error.status = 503; + return error; +} diff --git a/app/api/public-profiles/[handle]/route.js b/app/api/public-profiles/[handle]/route.js new file mode 100644 index 0000000..b739062 --- /dev/null +++ b/app/api/public-profiles/[handle]/route.js @@ -0,0 +1,42 @@ +import { notFound, ok, serverError } from "../../../../src/server/http"; +import { getSupabaseAdmin } from "../../../../src/server/supabase"; +import { normalizeHandle, serializePublicProfile } from "../../../../src/server/publicProfiles"; + +export async function GET(_request, { params }) { + try { + const { handle: rawHandle } = await params; + const handle = normalizeHandle(rawHandle); + if (!handle) return notFound("public profile not found"); + + const supabase = getSupabaseAdmin(); + const { data: profile, error } = await supabase.from("public_profiles").select("*").eq("handle", handle).single(); + if (error?.code === "PGRST116") return notFound("public profile not found"); + if (isMissingTableError(error)) return notFound("public profile not found"); + if (error) throw error; + + const { data: fieldNotes, error: notesError } = await supabase + .from("field_notes") + .select("id, title, content, created_at, public_published_at") + .eq("public_profile_id", profile.id) + .eq("is_public", true) + .eq("is_published", true) + .order("public_published_at", { ascending: false }) + .limit(40); + if (isMissingTableError(notesError) || isMissingColumnError(notesError)) return notFound("public profile not found"); + if (notesError) throw notesError; + + return ok({ profile: serializePublicProfile(profile, fieldNotes || []) }); + } catch (error) { + return serverError(error); + } +} + +function isMissingTableError(error) { + const message = `${error?.message || ""} ${error?.details || ""} ${error?.hint || ""}`.toLowerCase(); + return error?.code === "42P01" || error?.code === "PGRST205" || message.includes("could not find the table"); +} + +function isMissingColumnError(error) { + const message = `${error?.message || ""} ${error?.details || ""} ${error?.hint || ""}`.toLowerCase(); + return error?.code === "42703" || error?.code === "PGRST204" || (message.includes("could not find") && message.includes("column")); +} diff --git a/app/api/public-profiles/route.js b/app/api/public-profiles/route.js new file mode 100644 index 0000000..19bb225 --- /dev/null +++ b/app/api/public-profiles/route.js @@ -0,0 +1,95 @@ +import { badRequest, ok, serverError } from "../../../src/server/http"; +import { hashToken } from "../../../src/server/hash"; +import { getSupabaseAdmin } from "../../../src/server/supabase"; +import { cleanString } from "../../../src/server/validation"; +import { isValidHandle, normalizeHandle, serializePublicProfile } from "../../../src/server/publicProfiles"; + +export async function GET(request) { + try { + const url = new URL(request.url); + const sessionToken = cleanString(url.searchParams.get("sessionToken"), 256); + if (!sessionToken) return badRequest("session token is required"); + + const supabase = getSupabaseAdmin(); + const sessionTokenHash = hashToken(sessionToken); + const { data: profile, error } = await supabase + .from("public_profiles") + .select("*") + .eq("session_token_hash", sessionTokenHash) + .order("created_at", { ascending: true }) + .limit(1) + .maybeSingle(); + + if (isMissingTableError(error)) { + return ok({ profile: null, migrationRequired: true }); + } + if (error) throw error; + + return ok({ profile: profile ? serializePublicProfile(profile) : null }); + } catch (error) { + return serverError(error); + } +} + +export async function POST(request) { + try { + const body = await request.json(); + const sessionToken = cleanString(body.sessionToken, 256); + const handle = normalizeHandle(body.handle); + const displayName = cleanString(body.displayName || handle, 80); + const bio = cleanString(body.bio, 220); + + if (!sessionToken) return badRequest("session token is required"); + if (!isValidHandle(handle)) return badRequest("choose a handle with 2-30 letters, numbers, underscores, or dashes"); + + const supabase = getSupabaseAdmin(); + const sessionTokenHash = hashToken(sessionToken); + const { data: existingForHandle, error: handleError } = await supabase + .from("public_profiles") + .select("*") + .eq("handle", handle) + .maybeSingle(); + if (isMissingTableError(handleError)) return serverError(missingPublicProfileMigrationError()); + if (handleError) throw handleError; + if (existingForHandle && existingForHandle.session_token_hash !== sessionTokenHash) { + return badRequest("that handle is already taken"); + } + + const { data: existingForSession, error: sessionError } = await supabase + .from("public_profiles") + .select("*") + .eq("session_token_hash", sessionTokenHash) + .order("created_at", { ascending: true }) + .limit(1) + .maybeSingle(); + if (sessionError) throw sessionError; + + const mutation = existingForSession + ? supabase + .from("public_profiles") + .update({ handle, display_name: displayName || handle, bio, updated_at: new Date().toISOString() }) + .eq("id", existingForSession.id) + : supabase + .from("public_profiles") + .insert({ session_token_hash: sessionTokenHash, handle, display_name: displayName || handle, bio }); + + const { data: profile, error } = await mutation.select("*").single(); + if (error?.code === "23505") return badRequest("that handle is already taken"); + if (error) throw error; + + return ok({ profile: serializePublicProfile(profile) }); + } catch (error) { + return serverError(error); + } +} + +function isMissingTableError(error) { + const message = `${error?.message || ""} ${error?.details || ""} ${error?.hint || ""}`.toLowerCase(); + return error?.code === "42P01" || error?.code === "PGRST205" || message.includes("could not find the table"); +} + +function missingPublicProfileMigrationError() { + const error = new Error("Public profile migration is not applied yet. Run supabase/migrations/0015_public_profiles.sql."); + error.status = 503; + return error; +} diff --git a/app/api/spaces/[slug]/posts/route.js b/app/api/spaces/[slug]/posts/route.js index 977d0d9..5e48b8c 100644 --- a/app/api/spaces/[slug]/posts/route.js +++ b/app/api/spaces/[slug]/posts/route.js @@ -9,14 +9,17 @@ export async function POST(request, { params }) { const { slug } = await params; const body = await request.json(); const type = cleanString(body.type, 24); - const content = cleanString(body.content, 420); + const content = cleanString(body.content, type === "dump" || type === "field_note" ? 4000 : 420); const isAnonymous = body.isAnonymous !== false; const displayName = isAnonymous ? null : cleanString(body.displayName, 48) || "someone brave"; const sessionToken = cleanString(body.sessionToken, 256); const promptId = cleanString(body.promptId, 64) || null; + const dumpId = cleanString(body.dumpId, 64) || null; + const fieldNoteTitle = type === "field_note" ? cleanString(body.title, 120) : null; if (!slug) return badRequest("space slug is required"); if (!isValidPostType(type)) return badRequest("unsupported post type"); + if (type === "dump") return badRequest("raw dumps stay private. publish a field note to team reads instead."); if (!content) return badRequest("post content is required"); if (!sessionToken) return badRequest("session token is required"); @@ -32,6 +35,8 @@ export async function POST(request, { params }) { .insert({ space_id: space.id, prompt_id: promptId, + dump_id: dumpId, + field_note_title: fieldNoteTitle, type, content, is_anonymous: isAnonymous, diff --git a/app/api/spaces/[slug]/route.js b/app/api/spaces/[slug]/route.js index 58587a1..927a38f 100644 --- a/app/api/spaces/[slug]/route.js +++ b/app/api/spaces/[slug]/route.js @@ -8,7 +8,7 @@ import { cleanString } from "../../../../src/server/validation"; const DEFAULT_POST_LIMIT = 20; const MAX_POST_LIMIT = 40; -const POST_TYPES = new Set(["find", "thought", "rant", "win", "lol"]); +const POST_TYPES = new Set(["find", "thought", "rant", "win", "lol", "dump", "field_note"]); export async function GET(request, { params }) { try { @@ -39,7 +39,10 @@ export async function GET(request, { params }) { .limit(postLimit + 1); let postCountQuery = supabase.from("posts").select("id", { count: "exact", head: true }).eq("space_id", space.id); - if (postType) { + if (postType === "reads") { + postsQuery = postsQuery.eq("type", "field_note"); + postCountQuery = postCountQuery.eq("type", "field_note"); + } else if (postType) { postsQuery = postsQuery.eq("type", postType); postCountQuery = postCountQuery.eq("type", postType); } @@ -121,6 +124,7 @@ function parsePostCursor(value) { } function parsePostType(value) { + if (value === "reads") return "reads"; return POST_TYPES.has(value) ? value : ""; } diff --git a/app/dump/map/page.jsx b/app/dump/map/page.jsx new file mode 100644 index 0000000..e383e77 --- /dev/null +++ b/app/dump/map/page.jsx @@ -0,0 +1,9 @@ +import DumpPageClient from "../../../src/components/DumpPageClient"; + +export const metadata = { + title: "dump map", +}; + +export default function DumpMapPage() { + return ; +} diff --git a/app/dump/new/page.jsx b/app/dump/new/page.jsx new file mode 100644 index 0000000..6e341d3 --- /dev/null +++ b/app/dump/new/page.jsx @@ -0,0 +1,9 @@ +import DumpPageClient from "../../../src/components/DumpPageClient"; + +export const metadata = { + title: "new dump", +}; + +export default function NewDumpPage() { + return ; +} diff --git a/app/dump/page.jsx b/app/dump/page.jsx new file mode 100644 index 0000000..36e1b0f --- /dev/null +++ b/app/dump/page.jsx @@ -0,0 +1,10 @@ +import DumpPageClient from "../../src/components/DumpPageClient"; + +export const metadata = { + title: "your dump", + description: "a private-first place to put what is sitting in your head at work.", +}; + +export default function DumpPage() { + return ; +} diff --git a/app/public/[handle]/page.jsx b/app/public/[handle]/page.jsx new file mode 100644 index 0000000..a43e6b8 --- /dev/null +++ b/app/public/[handle]/page.jsx @@ -0,0 +1,11 @@ +import PublicProfilePage, { getPublicProfileMetadata } from "../../../src/components/PublicProfilePage"; + +export async function generateMetadata({ params }) { + const { handle } = await params; + return getPublicProfileMetadata(handle); +} + +export default async function RewrittenPublicProfilePage({ params }) { + const { handle } = await params; + return ; +} diff --git a/docs/backend-plan.md b/docs/backend-plan.md index f8f659e..42e1a10 100644 --- a/docs/backend-plan.md +++ b/docs/backend-plan.md @@ -89,6 +89,18 @@ create table anon_audit ( - `POST /api/spaces/:slug/first-post-dismissed` marks the creator-first prompt as dismissed. - `POST /api/cron/heartbeats` generates weekly heartbeats from anonymised post data. +## Dump V1 + +The dump feature keeps the no-signup model. Private dump entries are owned by the same browser session token pattern, but the database stores only `session_token_hash`, never the raw token. A dump is private on creation and cannot be posted directly into a room. + +Team reads show only approved field notes. The flow is: select private dumps, request an OpenAI draft through a server route, save the draft in `field_notes`, let the author edit it, then publish it as `posts.type = 'field_note'`. The old raw-dump team endpoint is intentionally blocked so private dumps stay for dumping thoughts, not public reading. + +`OPENAI_API_KEY`, `OPENAI_MODEL_FIELD_NOTE`, and `OPENAI_MAX_DAILY_DRAFTS` are server-only. The default model should stay cost-sensitive, currently `gpt-5.4-nano`, and the draft route sends only selected dumps, capped at 10 per request. Field-note drafting should produce publishable working-process notes: specific, human, readable, and useful enough for team reads or a public profile, while staying grounded only in the selected dumps. + +Public profiles and account migration are not in this implementation yet. When identity lands, signup must migrate existing dump rows and field-note drafts without changing visibility. + +Prototype public profiles now exist as a no-signup bridge: a browser session can claim one public handle and selectively add already-published field notes to `mumbl.wtf/@handle`. Private dumps and field-note drafts never appear there. This is intentionally per-note opt-in and should be replaced or migrated carefully when full identity arrives. + ## Local Setup 1. Create a Supabase project. diff --git a/docs/free-tier-compromises.md b/docs/free-tier-compromises.md index 87e2621..1a0a4c7 100644 --- a/docs/free-tier-compromises.md +++ b/docs/free-tier-compromises.md @@ -53,3 +53,19 @@ Current compromise: Future improvements: - Use daily culture snapshots once public-space volume grows. - Generate public culture summaries in a scheduled job instead of on-demand reads. + +## Dump + +Current compromise: +- Per-entry AI reflection uses a deterministic local reflector in the route handler, not an external AI provider. +- Team field-note drafting uses OpenAI only when the user clicks draft, sends selected dumps only, caps selection size, and rate-limits drafts per session with `OPENAI_MAX_DAILY_DRAFTS`. +- The private map is rendered from the user's fetched dump text in the browser, with no weekly insight cron yet. +- Team reads reuse the existing posts infrastructure, but only for approved `field_note` posts. Raw dumps are blocked from reads. + +Why: +- External AI calls and weekly insight jobs would add cost, secrets, and retry behavior before the core private dump loop is proven. +- The v1 promise is explicit visibility control and low-cost drafting, not model quality. + +Future improvements: +- Add provider-backed reflection behind an opt-in toggle once budget and privacy copy are settled. +- Generate `dump_insights` weekly for users with enough dumps, using a daily-or-weekly cron posture compatible with the current free-tier lane. diff --git a/docs/mumbl-dump-feature.md b/docs/mumbl-dump-feature.md new file mode 100644 index 0000000..5b37a26 --- /dev/null +++ b/docs/mumbl-dump-feature.md @@ -0,0 +1,237 @@ +# mumbl dump — feature spec + +## what this is + +a private-first space inside mumbl where anyone can dump what's on their mind about work — a thought, a process, a feeling, a realisation, a half-formed idea — at any point in the day. no pressure, no format, no audience unless you want one. over time it becomes a personal record of how you think and work. you can choose to share a dump with your team, or publish it publicly on your mumbl profile for anyone to read. + +the core principle: **private by default, public by deliberate choice. never the other way around.** + +the vibe: your dump is yours. messy is fine. one sentence is fine. a wall of text at 2am is fine. this is the place where you don't have to perform. + +--- + +## the three layers + +### 1. private dump (default) +- every entry is private by default. only you can see it. +- no signup required to start — works like the rest of mumbl, anonymous session first. +- write one sentence or five paragraphs. no format enforced. no title required. +- optional: AI reflection (not editing). after you write, AI can surface a thread — "you mentioned feeling stuck three times today — want to say more?" it reflects, it doesn't rewrite. ever. +- over time, AI builds a personal map of your dumps: recurring themes, emotional patterns, peak flow days, what you were working on and how it felt. shown as a private timeline only you can see. +- **nothing leaves your private dump without an explicit action from you.** + +### 2. team dump +- from any private dump, you can choose to drop it into your mumbl team space. +- it appears in the team feed with a different visual treatment — labelled "dump", longer form, more personal tone than a rant/win/lol post. +- teammates can react (same reaction system as the rest of mumbl) but cannot publicly comment — keeps it from feeling exposed. +- still anonymous by default unless the user has explicitly chosen to attach their name. + +### 3. team reads (the good part) +- inside every team room, a tab called **reads** surfaces dumps that team members have opted into sharing there. +- opt-in is per dump, not a global setting. you choose which dumps appear in reads. +- this is not a feed of everything — it's a curated window into how your teammates actually think. +- reading a senior engineer's dump on how they debug a hard problem, or how they felt about a rough sprint, is how coworkers stop being usernames and start being people. +- no reactions in the reads tab in v1 — just reading. keeps it feeling like a library not a performance stage. +- nothing appears in reads without an explicit opt-in action per dump. + +### 4. public profile +- you can publish any dump to your public mumbl profile: `mumbl.wtf/@username` +- this requires creating an identity — either by logging in with github/google or creating a mumbl handle. +- once public, it's readable by anyone visiting your profile. +- your profile becomes a public record of how you actually think and work. not a portfolio. not a linkedin. not a blog. a dump. +- every published entry can be unpublished at any time. no questions asked. + +Prototype note: before full auth exists, Mumbl supports a lighter version where an anonymous browser session claims a handle and adds selected published field notes to `mumbl.wtf/@handle`. This is still deliberate per-entry publishing. Drafts and private dumps stay out. + +--- + +## identity and persistence model + +this is the most important thing to get right. nothing is ever lost, nothing is ever exposed without explicit action. + +``` +anonymous session + ↓ +starts dumping (stored locally + server-side with anon session token) + ↓ +[optional] create mumbl handle OR link github/google + → all previous dumps migrate to the account + → nothing is lost + ↓ +[optional] choose to go public — per entry, never global + → dumps remain private unless explicitly published one by one +``` + +- **anonymous users can dump and keep everything private.** entries persist via session token in localStorage + server. +- **creating an account changes nothing about visibility.** zero entries become public on signup. +- **public is always a per-entry deliberate action.** there is no "make all public" setting. + +--- + +## data model additions + +### dump table +``` +id uuid primary key +session_id string (anon session OR user id after account creation) +content text +created_at timestamp +updated_at timestamp +visibility enum: 'private' | 'team' | 'public' +team_room_id uuid nullable (set when shared to team) +ai_reflection text nullable (shown only to author, never auto-generated) +published_at timestamp nullable +``` + +### user_profile table (new) +``` +id uuid primary key +handle string unique (e.g. 'disha') +display_name string nullable +bio string nullable +auth_provider enum: 'github' | 'google' | 'email' nullable +created_at timestamp +``` + +### dump_insights table (new) +``` +id uuid primary key +session_id string +insight_type enum: 'theme' | 'pattern' | 'streak' | 'graph_node' +content jsonb +generated_at timestamp +``` + +--- + +## routes + +| route | description | +|---|---| +| `/dump` | private dump home — write, view past dumps, see your map | +| `/dump/new` | quick dump composer — open immediately, no friction | +| `/dump/map` | personal knowledge map — themes, patterns, timeline | +| `/room/:id/reads` | team reads tab — opt-in dumps from teammates | +| `/@:handle` | public profile — all published dumps | +| `/@:handle/:dump-id` | single published dump | + +--- + +## UI behaviour + +### dump composer +- dead simple. one text area, full width, no toolbar, no formatting options. +- placeholder rotates to keep it fresh. examples: + - `what are you actually thinking about right now?` + - `say the thing you didn't say in standup.` + - `what happened today?` + - `what's been sitting in your head all week?` + - `dump it here.` +- character count optional, not enforced. +- below the text area, after writing: three soft action buttons + - `keep it private` (default, always the first option) + - `drop it in the team room` (only if user is in a room) + - `publish to my profile` (only if user has a handle) +- optional AI reflection — a small toggle labelled "ask AI to reflect on this." off by default. if on, after saving, a short reflection appears below the dump in muted text. it never modifies the original entry. + +### private dump feed +- chronological list, newest first. +- each dump shows: relative time ("3 hours ago"), first two lines, a small visibility pill (private / team / public). +- tap/click to expand. +- hover reveals: `share to team` and `publish` as ghost buttons — present but not pushy. + +### dump map (`/dump/map`) +- visual timeline of dumps with recurring themes as coloured threads. +- AI summary at the top, refreshed weekly: "lately you've been dumping about deployment anxiety, the new project, and something good that happened on thursdays." +- feels like looking at your own brain from the outside. private only. never shareable. + +### public profile (`/@handle`) +- clean reading experience. generous whitespace. no clutter. +- dumps listed newest first with the first line as the title and relative date. +- no reactions, no comments on public profiles in v1 — reading only. +- small "follow" placeholder button (UI only for now, no backend needed yet). +- bio at the top, short, written by the user. optional. + +--- + +## AI integration + +### 1. reflection (per dump, opt-in) +- triggered: user taps "ask AI to reflect on this" before or after saving. +- behaviour: reads the dump, identifies emotional texture and recurring threads, returns one short reflection — a question or an observation. does not summarise. does not rewrite. just listens and reflects back. +- shown inline below the dump in muted text, clearly labelled "ai reflection." +- can be dismissed or hidden. + +### 2. dump map insights (private, weekly) +- triggered: background job, weekly, for users with 5+ dumps. +- generates: top recurring themes, emotional arc of the week, flow vs stuck ratio, notable patterns. +- stored in `dump_insights` table, rendered in `/dump/map`. +- never shared externally. never visible to the team or public. + +### 3. heartbeat contribution (optional, future) +- if a user drops a dump into a team space, it can optionally contribute signal to the monday heartbeat alongside rant/win/lol posts. +- opt-in per dump. labelled separately in the heartbeat output as "from the dump." + +--- + +## what NOT to build (keep this list) + +- **no global explore or discover feed.** there is no browse-all-public-dumps feed for strangers. public profiles are visit-only via direct link. +- **no AI rewriting.** AI reflects and surfaces patterns. it never touches the content of a dump. +- **no forced signup.** anonymous dumping must work fully without an account. forever. +- **no public-by-default.** ever. not even as an option in settings. +- **no comments on public profiles in v1.** reactions and comments stay inside the team space only. +- **no notifications that feel like pressure.** no "you haven't dumped in 3 days!" no streaks. no guilt. + +--- + +## copy and microcopy + +get the words right — this is what makes it feel fun not clinical. + +| element | copy | +|---|---| +| page title | `your dump` | +| empty state | `nothing here yet. what's been sitting in your head?` | +| save button | `keep it private` | +| share button | `drop it in the room` | +| publish button | `put it out there` | +| AI reflection label | `ai heard this` | +| map empty state | `dump more to see patterns form.` | +| public profile tagline | `how [name] actually thinks at work.` | +| reads tab label | `reads` | +| reads empty state | `no one has dropped anything here yet. share a dump with the team.` | +| reads opt-in button | `add to team reads` | +| after first dump | `it's in the dump. no one can see it but you.` | + +--- + +## demo flow (for YC video) + +1. open mumbl.wtf — no signup. tap "start your dump." +2. write something real about today. one paragraph. save privately. +3. AI reflection appears below — one line, honest, not cheesy. +4. show the dump map with a seeded demo state — themes forming, a small timeline. +5. drop one dump into the team room — it appears in the feed, longer form, different texture to the rant/win/lol posts. +6. publish one dump to a profile — `mumbl.wtf/@disha` loads. clean. human. real. +7. end on the profile. let it sit for a second. no voiceover needed. + +--- + +## build order + +1. dump composer + private entry storage (anon session) +2. private dump feed +3. account creation + entry migration +4. team drop action + feed integration +5. public profile + publish action +6. AI reflection (per entry, opt-in) +7. dump map + weekly insights + +--- + +## the point + +every feature in mumbl returns something human to the person who gave something honest. the dump is the most personal version of that. you write to understand yourself. the team layer helps your coworkers understand you. the public layer helps the world know how you actually think and work. + +the mission is the same across all of it: make the biggest part of the day feel like a life, not something to get through. diff --git a/next-env.d.ts b/next-env.d.ts new file mode 100644 index 0000000..9edff1c --- /dev/null +++ b/next-env.d.ts @@ -0,0 +1,6 @@ +/// +/// +import "./.next/types/routes.d.ts"; + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/next.config.mjs b/next.config.mjs index e95c05f..83d1a2b 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -1,6 +1,14 @@ /** @type {import('next').NextConfig} */ const nextConfig = { allowedDevOrigins: ["127.0.0.1", "192.168.31.16"], + async rewrites() { + return [ + { + source: "/@:handle", + destination: "/public/:handle", + }, + ]; + }, }; export default nextConfig; diff --git a/package-lock.json b/package-lock.json index 5365b78..e8014e4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,11 @@ "react-dom": "^19.0.0" }, "devDependencies": { - "supabase": "^2.98.2" + "@types/node": "^25.9.1", + "@types/react": "^19.2.15", + "@types/react-dom": "^19.2.3", + "supabase": "^2.98.2", + "typescript": "^6.0.3" } }, "node_modules/@emnapi/runtime": { @@ -796,12 +800,32 @@ } }, "node_modules/@types/node": { - "version": "25.6.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.2.tgz", - "integrity": "sha512-sokuT28dxf9JT5Kady1fsXOvI4HVpjZa95NKT5y9PNTIrs2AsobR4GFAA90ZG8M+nxVRLysCXsVj6eGC7Vbrlw==", + "version": "25.9.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz", + "integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==", "license": "MIT", "dependencies": { - "undici-types": "~7.19.0" + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/@types/react": { + "version": "19.2.15", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.15.tgz", + "integrity": "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" } }, "node_modules/@types/ws": { @@ -898,6 +922,13 @@ "node": "^20.17.0 || >=22.9.0" } }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, "node_modules/data-uri-to-buffer": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", @@ -1374,10 +1405,24 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "node_modules/undici-types": { - "version": "7.19.2", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", - "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", "license": "MIT" }, "node_modules/web-streams-polyfill": { diff --git a/package.json b/package.json index d683006..575c1a6 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,10 @@ "react-dom": "^19.0.0" }, "devDependencies": { - "supabase": "^2.98.2" + "@types/node": "^25.9.1", + "@types/react": "^19.2.15", + "@types/react-dom": "^19.2.3", + "supabase": "^2.98.2", + "typescript": "^6.0.3" } } diff --git a/src/components/AppShell.jsx b/src/components/AppShell.jsx index cbab5f6..119b101 100644 --- a/src/components/AppShell.jsx +++ b/src/components/AppShell.jsx @@ -25,6 +25,9 @@ export default function AppShell({ children }) { mission + + dump + help shape mumbl diff --git a/src/components/DumpPageClient.jsx b/src/components/DumpPageClient.jsx new file mode 100644 index 0000000..f8d9462 --- /dev/null +++ b/src/components/DumpPageClient.jsx @@ -0,0 +1,1086 @@ +"use client"; + +import Link from "next/link"; +import { useEffect, useMemo, useState } from "react"; +import { + createDump, + deleteDump, + deleteFieldNote, + draftFieldNote, + fetchDumpMap, + fetchDumps, + fetchPublicProfileForSession, + publishFieldNote, + savePublicProfile, + setFieldNotePublic, + updateDump, + updateFieldNote, +} from "../lib/api"; +import { getDumpMemoryOptIn, getRecentSlug, setDumpMemoryOptIn } from "../lib/storage"; +import Toast from "./Toast"; + +const placeholders = [ + "what are you actually thinking about right now?", + "say the thing you didn't say in standup.", + "what happened today?", + "what's been sitting in your head all week?", + "dump it here.", +]; + +export default function DumpPageClient({ mode = "home" }) { + const [dumps, setDumps] = useState([]); + const [status, setStatus] = useState("loading"); + const [fieldNotes, setFieldNotes] = useState([]); + const [activePanel, setActivePanel] = useState("dumps"); + const [content, setContent] = useState(""); + const [wantsReflection, setWantsReflection] = useState(false); + const [isSaving, setIsSaving] = useState(false); + const [expandedId, setExpandedId] = useState(""); + const [recentSlug, setRecentSlug] = useState(""); + const [shareSlug, setShareSlug] = useState(""); + const [publishAnonymous, setPublishAnonymous] = useState(true); + const [displayName, setDisplayName] = useState(""); + const [publicProfile, setPublicProfile] = useState(null); + const [publicProfileStatus, setPublicProfileStatus] = useState("loading"); + const [publicHandleDraft, setPublicHandleDraft] = useState(""); + const [publicNameDraft, setPublicNameDraft] = useState(""); + const [publicBioDraft, setPublicBioDraft] = useState(""); + const [publicModalOpen, setPublicModalOpen] = useState(false); + const [selectedDumpIds, setSelectedDumpIds] = useState([]); + const [pendingDraft, setPendingDraft] = useState(false); + const [publishingNoteId, setPublishingNoteId] = useState(""); + const [mutatingNoteId, setMutatingNoteId] = useState(""); + const [publicMutatingNoteId, setPublicMutatingNoteId] = useState(""); + const [toast, setToast] = useState(""); + const placeholder = placeholders[(dumps.length + content.length) % placeholders.length]; + + useEffect(() => { + setRecentSlug(getRecentSlug("")); + }, []); + + useEffect(() => { + let mounted = true; + async function load() { + try { + const [result, profileResult] = await Promise.all([fetchDumps(), fetchPublicProfileForSession()]); + if (!mounted) return; + setDumps(result.dumps || []); + setFieldNotes(result.fieldNotes || []); + const nextProfile = profileResult.profile || null; + setPublicProfile(nextProfile); + setPublicProfileStatus(profileResult.migrationRequired ? "migration" : "ready"); + if (nextProfile) { + setPublicHandleDraft(nextProfile.handle || ""); + setPublicNameDraft(nextProfile.displayName || ""); + setPublicBioDraft(nextProfile.bio || ""); + } + setStatus("ready"); + } catch (error) { + if (!mounted) return; + setStatus("error"); + setPublicProfileStatus("error"); + setToast(error.message || "couldn't open your dump yet."); + } + } + load(); + return () => { + mounted = false; + }; + }, []); + + const map = useMemo(() => makeDumpMap(dumps), [dumps]); + const selectedDumps = dumps.filter((dump) => selectedDumpIds.includes(dump.id)); + + async function handleSave(event) { + event.preventDefault(); + const trimmed = content.trim(); + if (!trimmed || isSaving) return; + + setIsSaving(true); + try { + const result = await createDump({ content: trimmed, wantsReflection }); + setDumps((current) => [result.dump, ...current]); + setContent(""); + setWantsReflection(false); + setExpandedId(result.dump.id); + setToast("it's in the dump. no one can see it but you."); + } catch (error) { + setToast(error.message || "couldn't keep that yet."); + } finally { + setIsSaving(false); + } + } + + async function handleUpdateDump(dump, nextContent, nextWantsReflection) { + try { + const result = await updateDump({ dumpId: dump.id, content: nextContent, wantsReflection: nextWantsReflection }); + setDumps((current) => current.map((item) => (item.id === dump.id ? result.dump : item))); + setToast("saved."); + return result.dump; + } catch (error) { + setToast(error.message || "couldn't save that edit."); + throw error; + } + } + + async function handleDeleteDump(dump) { + try { + await deleteDump(dump.id); + setDumps((current) => current.filter((item) => item.id !== dump.id)); + setSelectedDumpIds((current) => current.filter((id) => id !== dump.id)); + setExpandedId((current) => (current === dump.id ? "" : current)); + setToast("deleted from your dump."); + } catch (error) { + setToast(error.message || "couldn't delete that dump."); + throw error; + } + } + + async function handleDraftFieldNote() { + if (!selectedDumpIds.length || pendingDraft) { + setToast("pick one or more dumps first."); + return; + } + + setPendingDraft(true); + try { + const result = await draftFieldNote({ dumpIds: selectedDumpIds }); + setFieldNotes((current) => [result.fieldNote, ...current]); + setSelectedDumpIds([]); + setActivePanel("notes"); + setToast(result.visibilityReminder || "draft ready. read it before it goes anywhere."); + } catch (error) { + setToast(error.message || "couldn't draft that field note."); + } finally { + setPendingDraft(false); + } + } + + async function handlePublishFieldNote(fieldNote, edits) { + const slug = (shareSlug || recentSlug).trim(); + if (!slug || publishingNoteId) { + setToast("paste a room slug first."); + return; + } + + setPublishingNoteId(fieldNote.id); + try { + const result = await publishFieldNote({ + fieldNoteId: fieldNote.id, + slug, + title: edits.title, + content: edits.content, + isAnonymous: publishAnonymous, + displayName, + }); + setFieldNotes((current) => current.map((item) => (item.id === fieldNote.id ? result.fieldNote : item))); + setRecentSlug(slug); + setShareSlug(""); + setDisplayName(""); + setToast("published to team reads."); + } catch (error) { + setToast(error.message || "couldn't publish that field note."); + } finally { + setPublishingNoteId(""); + } + } + + async function handleUpdateFieldNote(fieldNote, edits) { + setMutatingNoteId(fieldNote.id); + try { + const result = await updateFieldNote({ fieldNoteId: fieldNote.id, title: edits.title, content: edits.content }); + setFieldNotes((current) => current.map((item) => (item.id === fieldNote.id ? result.fieldNote : item))); + setToast(fieldNote.isPublished ? "updated in team reads." : "draft saved."); + } catch (error) { + setToast(error.message || "couldn't save that field note."); + throw error; + } finally { + setMutatingNoteId(""); + } + } + + async function handleDeleteFieldNote(fieldNote) { + setMutatingNoteId(fieldNote.id); + try { + await deleteFieldNote(fieldNote.id); + setFieldNotes((current) => current.filter((item) => item.id !== fieldNote.id)); + setToast(fieldNote.isPublished ? "removed from team reads." : "draft deleted."); + } catch (error) { + setToast(error.message || "couldn't delete that field note."); + throw error; + } finally { + setMutatingNoteId(""); + } + } + + async function handleSavePublicProfile(event) { + event?.preventDefault(); + if (publicProfileStatus === "saving") return; + + setPublicProfileStatus("saving"); + try { + const result = await savePublicProfile({ + handle: publicHandleDraft, + displayName: publicNameDraft, + bio: publicBioDraft, + }); + setPublicProfile(result.profile); + setPublicHandleDraft(result.profile.handle || ""); + setPublicNameDraft(result.profile.displayName || ""); + setPublicBioDraft(result.profile.bio || ""); + setPublicProfileStatus("ready"); + setToast(`@${result.profile.handle} is yours. choose which field notes go public.`); + } catch (error) { + setPublicProfileStatus("ready"); + setToast(error.message || "couldn't save that public handle."); + } + } + + async function handleToggleFieldNotePublic(fieldNote, isPublic) { + if (!publicProfile?.handle) { + setToast("choose a public handle first."); + return; + } + + setPublicMutatingNoteId(fieldNote.id); + try { + const result = await setFieldNotePublic({ fieldNoteId: fieldNote.id, isPublic, handle: publicProfile.handle }); + setFieldNotes((current) => current.map((item) => (item.id === fieldNote.id ? result.fieldNote : item))); + setToast(isPublic ? `added to @${publicProfile.handle}.` : `removed from @${publicProfile.handle}.`); + } catch (error) { + setToast(error.message || "couldn't update your public profile."); + } finally { + setPublicMutatingNoteId(""); + } + } + + function toggleSelected(dumpId) { + setSelectedDumpIds((current) => + current.includes(dumpId) ? current.filter((id) => id !== dumpId) : [...current, dumpId].slice(0, 10), + ); + } + + if (mode === "map") { + return ; + } + + return ( +
+
+
+

private · first

+

your dump

+

Write the messy thing. Keep it private. Turn only the useful thread into a field note later.

+
+
+ + see the map + + + {recentSlug && ( + + team reads + + )} +
+
+ +
+
+