Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
14 changes: 14 additions & 0 deletions app/[publicHandle]/page.jsx
Original file line number Diff line number Diff line change
@@ -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 <PublicProfilePage rawHandle={publicHandle} />;
}
62 changes: 62 additions & 0 deletions app/api/dumps/[dumpId]/route.js
Original file line number Diff line number Diff line change
@@ -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);
}
}
9 changes: 9 additions & 0 deletions app/api/dumps/[dumpId]/team/route.js
Original file line number Diff line number Diff line change
@@ -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);
}
}
92 changes: 92 additions & 0 deletions app/api/dumps/field-notes/[fieldNoteId]/public/route.js
Original file line number Diff line number Diff line change
@@ -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;
}
82 changes: 82 additions & 0 deletions app/api/dumps/field-notes/[fieldNoteId]/publish/route.js
Original file line number Diff line number Diff line change
@@ -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);
}
}
91 changes: 91 additions & 0 deletions app/api/dumps/field-notes/[fieldNoteId]/route.js
Original file line number Diff line number Diff line change
@@ -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);
}
}
Loading
Loading