Files
deardiary/backend/src/routes/entries.ts
lotherk 754fea73c6 feat: immutable entries + full task logging
Entries now immutable once journal is generated:
- Edit/delete returns ENTRY_IMMUTABLE error if journal exists
- Frontend shows lock message and hides delete button
- Delete Journal button to unlock entries

Task logging now stores full JSON:
- request: full JSON request sent to AI provider
- response: full JSON response from AI provider
- prompt: formatted human-readable prompt

Prompt structure:
1. System prompt
2. Previous diary entries (journals)
3. Today's entries
2026-03-26 22:05:52 +00:00

177 lines
5.0 KiB
TypeScript

import { Hono } from 'hono';
import { HonoEnv } from '../lib/types';
export const entriesRoutes = new Hono<HonoEnv>();
entriesRoutes.post('/', async (c) => {
const userId = c.get('userId');
const prisma = c.get('prisma');
const mediaDir = c.env.MEDIA_DIR || './data/media';
const body = await c.req.json();
const { date, type, content, metadata } = body;
if (!date || !type || !content) {
return c.json({ data: null, error: { code: 'VALIDATION_ERROR', message: 'date, type, and content are required' } }, 400);
}
const validTypes = ['text', 'voice', 'photo', 'health', 'location'];
if (!validTypes.includes(type)) {
return c.json({ data: null, error: { code: 'VALIDATION_ERROR', message: `type must be one of: ${validTypes.join(', ')}` } }, 400);
}
const entry = await prisma.entry.create({
data: {
userId,
date,
type,
content,
metadata: metadata ? JSON.stringify(metadata) : null,
},
});
return c.json({ data: entry, error: null }, 201);
});
entriesRoutes.get('/:id', async (c) => {
const userId = c.get('userId');
const { id } = c.req.param();
const prisma = c.get('prisma');
const entry = await prisma.entry.findFirst({
where: { id, userId },
});
if (!entry) {
return c.json({ data: null, error: { code: 'NOT_FOUND', message: 'Entry not found' } }, 404);
}
return c.json({ data: entry, error: null });
});
entriesRoutes.put('/:id', async (c) => {
const userId = c.get('userId');
const { id } = c.req.param();
const prisma = c.get('prisma');
const body = await c.req.json();
const { content, metadata } = body;
const existing = await prisma.entry.findFirst({
where: { id, userId },
});
if (!existing) {
return c.json({ data: null, error: { code: 'NOT_FOUND', message: 'Entry not found' } }, 404);
}
const journal = await prisma.journal.findFirst({ where: { userId, date: existing.date } });
if (journal) {
return c.json({ data: null, error: { code: 'ENTRY_IMMUTABLE', message: 'Cannot edit entry: journal already generated. Delete the journal first.' } }, 400);
}
const entry = await prisma.entry.update({
where: { id },
data: {
content: content ?? existing.content,
metadata: metadata !== undefined ? JSON.stringify(metadata) : existing.metadata,
},
});
return c.json({ data: entry, error: null });
});
entriesRoutes.delete('/:id', async (c) => {
const userId = c.get('userId');
const { id } = c.req.param();
const prisma = c.get('prisma');
const existing = await prisma.entry.findFirst({
where: { id, userId },
});
if (!existing) {
return c.json({ data: null, error: { code: 'NOT_FOUND', message: 'Entry not found' } }, 404);
}
const journal = await prisma.journal.findFirst({ where: { userId, date: existing.date } });
if (journal) {
return c.json({ data: null, error: { code: 'ENTRY_IMMUTABLE', message: 'Cannot delete entry: journal already generated. Delete the journal first.' } }, 400);
}
await prisma.entry.delete({ where: { id } });
return c.json({ data: { deleted: true }, error: null });
});
entriesRoutes.post('/:id/photo', async (c) => {
const userId = c.get('userId');
const { id } = c.req.param();
const prisma = c.get('prisma');
const mediaDir = c.env.MEDIA_DIR || './data/media';
const entry = await prisma.entry.findFirst({
where: { id, userId },
});
if (!entry) {
return c.json({ data: null, error: { code: 'NOT_FOUND', message: 'Entry not found' } }, 404);
}
const body = await c.req.parseBody();
const file = body.file;
if (!file || !(file instanceof File)) {
return c.json({ data: null, error: { code: 'VALIDATION_ERROR', message: 'No file provided' } }, 400);
}
const ext = file.name.split('.').pop() || 'jpg';
const fileName = `${id}.${ext}`;
const userMediaDir = `${mediaDir}/${userId}/${entry.date}`;
const filePath = `${userMediaDir}/${fileName}`;
await Bun.write(filePath, file);
await prisma.entry.update({
where: { id },
data: { mediaPath: filePath },
});
return c.json({ data: { mediaPath: filePath }, error: null }, 201);
});
entriesRoutes.post('/:id/voice', async (c) => {
const userId = c.get('userId');
const { id } = c.req.param();
const prisma = c.get('prisma');
const mediaDir = c.env.MEDIA_DIR || './data/media';
const entry = await prisma.entry.findFirst({
where: { id, userId },
});
if (!entry) {
return c.json({ data: null, error: { code: 'NOT_FOUND', message: 'Entry not found' } }, 404);
}
const body = await c.req.parseBody();
const file = body.file;
if (!file || !(file instanceof File)) {
return c.json({ data: null, error: { code: 'VALIDATION_ERROR', message: 'No audio file provided' } }, 400);
}
const fileName = `${id}.webm`;
const userMediaDir = `${mediaDir}/${userId}/${entry.date}`;
const filePath = `${userMediaDir}/${fileName}`;
await Bun.write(filePath, file);
await prisma.entry.update({
where: { id },
data: { mediaPath: filePath },
});
return c.json({ data: { mediaPath: filePath }, error: null }, 201);
});