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
10 changes: 10 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -85,3 +85,13 @@ NOKV_OBJECT_ACCESS_KEY_ID=rustfsadmin
NOKV_OBJECT_SECRET_ACCESS_KEY=rustfsadmin
NOKV_WORKBENCH_ROOT=/agents/echojournal/wb
NOKV_SESSION_SECRET= # openssl rand -hex 32; signs per-user session refs

# =================================================================
# Data backend selection
# =================================================================
# supabase (default) — Postgres + Storage, current production path
# nokv — journal data lives in per-user NoKV workbenches (jr-{userId});
# reads fall back to Supabase when NoKV is unreachable, writes 503.
# Requires the full NOKV_* block above. Design:
# docs/nokv-data-migration-design.md
DATA_BACKEND=supabase
426 changes: 426 additions & 0 deletions docs/nokv-data-migration-design.md

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions src/app/api/agent/tools/context/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { after } from 'next/server';
import { auth } from '@clerk/nextjs/server';
import { z } from 'zod';
import { createAdminClient } from '@/lib/supabase/admin';
import { getJournalRepo } from '@/lib/data';
import { fetchUserContext } from '@/lib/agent/context';
import type { ContextRequest } from '@/lib/agent/context';
import { isTrustedOrigin } from '@/lib/security';
Expand Down Expand Up @@ -56,9 +56,9 @@ export async function POST(request: NextRequest) {
);
}

const supabase = createAdminClient();
const repo = getJournalRepo();
const startedAt = Date.now();
const context = await fetchUserContext(supabase, userId, payload);
const context = await fetchUserContext(repo, userId, payload);

// Optional NoKV trace, written after the response is sent.
const sessionRef = request.headers.get('x-echo-session');
Expand Down
41 changes: 8 additions & 33 deletions src/app/api/audio/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from 'next/server';
import { createAdminClient } from '@/lib/supabase/admin';
import { auth } from '@clerk/nextjs/server';
import { getJournalRepo } from '@/lib/data';

export async function GET(
request: NextRequest,
Expand All @@ -21,48 +21,23 @@ export async function GET(
);
}

const supabase = createAdminClient();
const repo = getJournalRepo();
const audio = await repo.getAudioBlob(userId, id);

// First, verify that the audio file belongs to the authenticated user
const { data: audioFile, error: audioFileError } = await supabase
.from('audio_files')
.select('storage_path, mime_type')
.eq('id', id)
.eq('user_id', userId)
.single();

if (audioFileError || !audioFile) {
if (!audio) {
return NextResponse.json(
{ error: 'Audio file not found or access denied' },
{ status: 404 }
);
}

// Get the audio file from Supabase Storage
const { data: audioData, error: storageError } = await supabase.storage
.from('audio-files')
.download(audioFile.storage_path);

if (storageError || !audioData) {
// eslint-disable-next-line no-console
console.error('Error downloading audio file:', storageError);
return NextResponse.json(
{ error: 'Failed to retrieve audio file' },
{ status: 500 }
);
}

// Convert blob to array buffer
const buffer = await audioData.arrayBuffer();

// Return the audio file with appropriate headers
return new NextResponse(buffer, {
return new NextResponse(audio.data, {
status: 200,
headers: {
'Content-Type': audioFile.mime_type || 'audio/webm',
'Content-Length': buffer.byteLength.toString(),
'Cache-Control': 'private, max-age=3600', // Cache for 1 hour
'Accept-Ranges': 'bytes'
'Content-Type': audio.mimeType || 'audio/webm',
'Content-Length': audio.data.byteLength.toString(),
'Cache-Control': 'private, max-age=3600' // Cache for 1 hour
}
});
} catch (error) {
Expand Down
106 changes: 40 additions & 66 deletions src/app/api/generate-daily-summary/route.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { NextRequest, NextResponse } from 'next/server';
import { after } from 'next/server';
import OpenAI from 'openai';
import { createAdminClient } from '@/lib/supabase/admin';
import { auth } from '@clerk/nextjs/server';
import { getJournalRepo, NokvUnavailableError } from '@/lib/data';
import type { DailySummary, JournalEntry, MoodEntry } from '@/lib/data';
import { syncReflectionsForDate } from '@/lib/reflections/sync';
import { getUtcRangeForDate } from '@/lib/timezone';
import { isTrustedOrigin } from '@/lib/security';
import { SUMMARY_MODEL } from '@/lib/ai/models';

Expand Down Expand Up @@ -41,70 +41,50 @@ export async function POST(request: NextRequest) {
);
}

const supabase = createAdminClient();
const { start: dayStart, end: dayEnd } = getUtcRangeForDate(date);

const { data: transcripts, error: transcriptsError } = await supabase
.from('transcripts')
.select(
`
id,
text,
rephrased_text,
created_at,
audio_files!inner(
created_at
)
`
)
.eq('user_id', userId)
.gte('audio_files.created_at', dayStart)
.lte('audio_files.created_at', dayEnd)
.order('created_at', { ascending: true });

if (transcriptsError) {
const repo = getJournalRepo();

// Step 1: Get all journal entries for the requested date
let entries: JournalEntry[];
try {
entries = await repo.listEntriesForDay(userId, date);
} catch (entriesError) {
// eslint-disable-next-line no-console
console.error('Error fetching transcripts:', transcriptsError);
console.error('Error fetching transcripts:', entriesError);
return NextResponse.json(
{ error: 'Failed to fetch transcripts' },
{ status: 500 }
);
}

if (!transcripts || transcripts.length === 0) {
if (entries.length === 0) {
return NextResponse.json({
success: true,
message: 'No journal entries found for this date'
});
}

// Step 2: Get daily mood data
const { data: moodData, error: moodError } = await supabase
.from('daily_question')
.select('day_quality, emotions')
.eq('user_id', userId)
.gte('created_at', dayStart)
.lte('created_at', dayEnd)
.single();

if (moodError && moodError.code !== 'PGRST116') {
// Step 2: Get daily mood data (non-fatal when unavailable)
let mood: MoodEntry | null = null;
try {
mood = await repo.getMood(userId, date);
} catch (moodError) {
// eslint-disable-next-line no-console
console.error('Error fetching mood data:', moodError);
}

// Step 3: Generate summary using GPT-4o
const journalTexts = transcripts
.map((t, index) => {
const time = new Date(t.created_at!).toLocaleTimeString('en-US', {
const journalTexts = entries
.map((entry, index) => {
const time = new Date(entry.created_at).toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit'
});
return `Entry ${index + 1} (${time}): ${t.rephrased_text || t.text}`;
return `Entry ${index + 1} (${time}): ${entry.transcript.rephrased_text || entry.transcript.text}`;
})
.join('\n\n');

const moodContext = moodData
? `\nToday's mood: ${moodData.day_quality}, feeling ${moodData.emotions.join(', ')}.`
const moodContext = mood
? `\nToday's mood: ${mood.day_quality}, feeling ${mood.emotions.join(', ')}.`
: '';

const summaryResponse = await openai.chat.completions.create({
Expand All @@ -113,7 +93,7 @@ export async function POST(request: NextRequest) {
{
role: 'system',
content: `You are a thoughtful journal assistant that creates concise daily summaries.

Your task is to:
- Synthesize multiple journal entries into a coherent daily narrative
- Maintain first-person perspective throughout
Expand All @@ -122,7 +102,7 @@ export async function POST(request: NextRequest) {
- Keep the summary between 3-5 sentences
- Make it reflective and meaningful
- Consider the overall mood context if provided

The summary should read like a thoughtful reflection on the day, not just a list of events.`
},
{
Expand All @@ -136,26 +116,21 @@ export async function POST(request: NextRequest) {
const summary = summaryResponse.choices[0]?.message?.content || '';

// Step 4: Upsert daily summary
const { data: summaryData, error: summaryError } = await supabase
.from('daily_summaries')
.upsert(
{
user_id: userId,
date: date,
summary: summary,
entry_count: transcripts.length,
mood_quality: moodData?.day_quality || null,
dominant_emotions: moodData?.emotions || null,
updated_at: new Date().toISOString()
},
{
onConflict: 'user_id,date'
}
)
.select()
.single();

if (summaryError) {
let summaryData: DailySummary;
try {
summaryData = await repo.upsertDailySummary(userId, date, {
summary,
entryCount: entries.length,
moodQuality: mood?.day_quality || null,
dominantEmotions: mood?.emotions || []
});
} catch (summaryError) {
if (summaryError instanceof NokvUnavailableError) {
return NextResponse.json(
{ error: 'Data backend temporarily unavailable' },
{ status: 503 }
);
}
// eslint-disable-next-line no-console
console.error('Error saving summary:', summaryError);
return NextResponse.json(
Expand All @@ -169,7 +144,6 @@ export async function POST(request: NextRequest) {
after(async () => {
try {
await syncReflectionsForDate({
supabase,
openai,
userId,
anchorDate: date
Expand All @@ -184,7 +158,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json({
success: true,
summary: summary,
entryCount: transcripts.length,
entryCount: entries.length,
summaryId: summaryData.id
});
} catch (error) {
Expand Down
Loading
Loading