feat: terminology fix (Entry→Event), diary page generation, settings refactor

- Rename Entry→Event throughout frontend and backend
- Change 'journal' terminology to 'diary page'
- Add professional footer with links
- Redirect to diary page after generation
- Error handling for generation failures
- Fix settings to store per-provider configs in providerSettings
- Backend reads API key from providerSettings
- Use prisma db push instead of migrate for schema sync
- Clean up duplicate entries.ts file
This commit is contained in:
lotherk
2026-03-26 23:10:33 +00:00
parent 754fea73c6
commit deaf496a7d
18 changed files with 289 additions and 256 deletions

View File

@@ -19,10 +19,10 @@ CREATE TABLE IF NOT EXISTS "ApiKey" (
CONSTRAINT "ApiKey_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE INDEX IF NOT EXISTS "ApiKey_userId_key" ON "ApiKey"("userId");
CREATE UNIQUE INDEX IF NOT EXISTS "ApiKey_keyHash_key" ON "ApiKey"("keyHash");
CREATE INDEX IF NOT EXISTS "ApiKey_userId_key" ON "ApiKey"("userId");
CREATE TABLE IF NOT EXISTS "Entry" (
CREATE TABLE IF NOT EXISTS "Event" (
"id" TEXT NOT NULL PRIMARY KEY,
"userId" TEXT NOT NULL,
"date" TEXT NOT NULL,
@@ -32,18 +32,18 @@ CREATE TABLE IF NOT EXISTS "Entry" (
"metadata" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "Entry_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
CONSTRAINT "Event_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE INDEX IF NOT EXISTS "Entry_userId_date_key" ON "Entry"("userId", "date");
CREATE INDEX IF NOT EXISTS "Entry_date_key" ON "Entry"("date");
CREATE INDEX IF NOT EXISTS "Event_userId_date_key" ON "Event"("userId", "date");
CREATE INDEX IF NOT EXISTS "Event_date_key" ON "Event"("date");
CREATE TABLE IF NOT EXISTS "Journal" (
"id" TEXT NOT NULL PRIMARY KEY,
"userId" TEXT NOT NULL,
"date" TEXT NOT NULL,
"content" TEXT NOT NULL,
"entryCount" INTEGER NOT NULL,
"eventCount" INTEGER NOT NULL,
"generatedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Journal_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
@@ -86,7 +86,6 @@ CREATE TABLE IF NOT EXISTS "Settings" (
CONSTRAINT "Settings_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- Create _prisma_migrations table for Prisma migrate tracking
CREATE TABLE IF NOT EXISTS "_prisma_migrations" (
"id" TEXT NOT NULL PRIMARY KEY,
"checksum" TEXT NOT NULL,
@@ -98,6 +97,5 @@ CREATE TABLE IF NOT EXISTS "_prisma_migrations" (
"applied_steps_count" INTEGER NOT NULL DEFAULT 0
);
-- Record this migration
INSERT INTO "_prisma_migrations" ("id", "checksum", "finished_at", "migration_name", "applied_steps_count")
VALUES (lower(hex(randomblob(16))), 'init', datetime('now'), '00000000000000_init', 1);

View File

@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "sqlite"

View File

@@ -15,7 +15,7 @@ model User {
updatedAt DateTime @updatedAt
apiKeys ApiKey[]
entries Entry[]
events Event[]
journals Journal[]
tasks Task[]
settings Settings?
@@ -34,7 +34,7 @@ model ApiKey {
@@index([userId])
}
model Entry {
model Event {
id String @id @default(uuid())
userId String
date String
@@ -56,7 +56,7 @@ model Journal {
userId String
date String
content String
entryCount Int
eventCount Int
generatedAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)

View File

@@ -147,7 +147,7 @@ app.get('/api/v1/days', async (c) => {
const userId = await getUserId(c);
if (!userId) return c.json({ data: null, error: { code: 'UNAUTHORIZED', message: 'Invalid API key' } }, 401);
const days = await prisma.entry.groupBy({
const days = await prisma.event.groupBy({
by: ['date'],
where: { userId },
_count: { id: true },
@@ -163,7 +163,7 @@ app.get('/api/v1/days', async (c) => {
const result = days.map(day => ({
date: day.date,
entryCount: day._count.id,
eventCount: day._count.id,
hasJournal: journalMap.has(day.date),
journalGeneratedAt: journalMap.get(day.date)?.generatedAt,
}));
@@ -181,12 +181,12 @@ app.get('/api/v1/days/:date', async (c) => {
return c.json({ data: null, error: { code: 'VALIDATION_ERROR', message: 'Invalid date format. Use YYYY-MM-DD' } }, 400);
}
const [entries, journal] = await Promise.all([
prisma.entry.findMany({ where: { userId, date }, orderBy: { createdAt: 'asc' } }),
const [events, journal] = await Promise.all([
prisma.event.findMany({ where: { userId, date }, orderBy: { createdAt: 'asc' } }),
prisma.journal.findFirst({ where: { userId, date } }),
]);
return c.json({ data: { date, entries, journal }, error: null });
return c.json({ data: { date, events, journal }, error: null });
});
app.delete('/api/v1/days/:date', async (c) => {
@@ -196,15 +196,31 @@ app.delete('/api/v1/days/:date', async (c) => {
const { date } = c.req.param();
await prisma.$transaction([
prisma.entry.deleteMany({ where: { userId, date } }),
prisma.event.deleteMany({ where: { userId, date } }),
prisma.journal.deleteMany({ where: { userId, date } }),
]);
return c.json({ data: { deleted: true }, error: null });
});
// Entries routes
app.post('/api/v1/entries', async (c) => {
// Delete journal only (keeps events)
app.delete('/api/v1/journal/:date', async (c) => {
const userId = await getUserId(c);
if (!userId) return c.json({ data: null, error: { code: 'UNAUTHORIZED', message: 'Invalid API key' } }, 401);
const { date } = c.req.param();
const journal = await prisma.journal.findFirst({ where: { userId, date } });
if (!journal) {
return c.json({ data: null, error: { code: 'NOT_FOUND', message: 'Journal not found' } }, 404);
}
await prisma.journal.delete({ where: { id: journal.id } });
return c.json({ data: { deleted: true }, error: null });
});
// Events routes
app.post('/api/v1/events', async (c) => {
const userId = await getUserId(c);
if (!userId) return c.json({ data: null, error: { code: 'UNAUTHORIZED', message: 'Invalid API key' } }, 401);
@@ -214,45 +230,45 @@ app.post('/api/v1/entries', async (c) => {
return c.json({ data: null, error: { code: 'VALIDATION_ERROR', message: 'date, type, and content are required' } }, 400);
}
const validTypes = ['text', 'voice', 'photo', 'health', 'location'];
const validTypes = ['event'];
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({
const event = await prisma.event.create({
data: { userId, date, type, content, metadata: metadata ? JSON.stringify(metadata) : null },
});
return c.json({ data: entry, error: null }, 201);
return c.json({ data: event, error: null }, 201);
});
app.get('/api/v1/entries/:id', async (c) => {
app.get('/api/v1/events/:id', async (c) => {
const userId = await getUserId(c);
if (!userId) return c.json({ data: null, error: { code: 'UNAUTHORIZED', message: 'Invalid API key' } }, 401);
const { id } = c.req.param();
const entry = await prisma.entry.findFirst({ where: { id, userId } });
const event = await prisma.event.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 });
if (!event) return c.json({ data: null, error: { code: 'NOT_FOUND', message: 'Event not found' } }, 404);
return c.json({ data: event, error: null });
});
app.put('/api/v1/entries/:id', async (c) => {
app.put('/api/v1/events/:id', async (c) => {
const userId = await getUserId(c);
if (!userId) return c.json({ data: null, error: { code: 'UNAUTHORIZED', message: 'Invalid API key' } }, 401);
const { id } = c.req.param();
const { content, metadata } = await c.req.json();
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 existing = await prisma.event.findFirst({ where: { id, userId } });
if (!existing) return c.json({ data: null, error: { code: 'NOT_FOUND', message: 'Event 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);
return c.json({ data: null, error: { code: 'EVENT_IMMUTABLE', message: 'Cannot edit event: journal already generated. Delete the journal first.' } }, 400);
}
const entry = await prisma.entry.update({
const event = await prisma.event.update({
where: { id },
data: {
content: content ?? existing.content,
@@ -260,23 +276,23 @@ app.put('/api/v1/entries/:id', async (c) => {
},
});
return c.json({ data: entry, error: null });
return c.json({ data: event, error: null });
});
app.delete('/api/v1/entries/:id', async (c) => {
app.delete('/api/v1/events/:id', async (c) => {
const userId = await getUserId(c);
if (!userId) return c.json({ data: null, error: { code: 'UNAUTHORIZED', message: 'Invalid API key' } }, 401);
const { id } = c.req.param();
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 existing = await prisma.event.findFirst({ where: { id, userId } });
if (!existing) return c.json({ data: null, error: { code: 'NOT_FOUND', message: 'Event 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);
return c.json({ data: null, error: { code: 'EVENT_IMMUTABLE', message: 'Cannot delete event: journal already generated. Delete the journal first.' } }, 400);
}
await prisma.entry.delete({ where: { id } });
await prisma.event.delete({ where: { id } });
return c.json({ data: { deleted: true }, error: null });
});
@@ -287,27 +303,30 @@ app.post('/api/v1/journal/generate/:date', async (c) => {
const { date } = c.req.param();
const [entries, settings] = await Promise.all([
prisma.entry.findMany({ where: { userId, date }, orderBy: { createdAt: 'asc' } }),
const [events, settings] = await Promise.all([
prisma.event.findMany({ where: { userId, date }, orderBy: { createdAt: 'asc' } }),
prisma.settings.findUnique({ where: { userId } }),
]);
if (entries.length === 0) {
return c.json({ data: null, error: { code: 'NO_ENTRIES', message: 'No entries found for this date' } }, 400);
if (events.length === 0) {
return c.json({ data: null, error: { code: 'NO_EVENTS', message: 'No events found for this date' } }, 400);
}
const provider = settings?.aiProvider || 'groq';
const providerSettings = settings?.providerSettings ? JSON.parse(settings.providerSettings) : {};
const providerConfig = providerSettings[provider] || {};
const apiKey = providerConfig.apiKey || settings?.aiApiKey;
if ((provider === 'openai' || provider === 'anthropic' || provider === 'groq') && !settings?.aiApiKey) {
if ((provider === 'openai' || provider === 'anthropic' || provider === 'groq') && !apiKey) {
return c.json({ data: null, error: { code: 'NO_AI_CONFIG', message: 'AI not configured. Please set up your API key in settings.' } }, 400);
}
// Build entries text
const entriesText = entries.map(entry => {
let text = `[${entry.type.toUpperCase()}] ${entry.createdAt.toISOString()}\n${entry.content}`;
if (entry.metadata) {
// Build events text
const eventsText = events.map(event => {
let text = `[EVENT] ${event.createdAt.toISOString()}\n${event.content}`;
if (event.metadata) {
try {
const meta = JSON.parse(entry.metadata);
const meta = JSON.parse(event.metadata);
if (meta.location) text += `\nLocation: ${meta.location.lat}, ${meta.location.lng}`;
if (meta.duration) text += `\nDuration: ${meta.duration}s`;
} catch {}
@@ -342,15 +361,15 @@ app.post('/api/v1/journal/generate/:date', async (c) => {
}
}
// Build prompts: 1. system prompt, 2. previous journals, 3. today's entries
// Build prompts: 1. system prompt, 2. previous journals, 3. today's events
const systemPrompt = settings?.journalPrompt || 'You are a thoughtful journal writer.';
const userPrompt = `${previousJournalsText}ENTRIES FROM TODAY (${date}):\n${entriesText}\n\nWrite a thoughtful, reflective journal entry based on the entries above.`;
const userPrompt = `${previousJournalsText}EVENTS FROM TODAY (${date}):\n${eventsText}\n\nWrite a thoughtful, reflective journal entry based on the events above.`;
console.log(`[Journal Generate] Date: ${date}, Context days: ${contextDays}, Entries: ${entries.length}`);
console.log(`[Journal Generate] Date: ${date}, Context days: ${contextDays}, Events: ${events.length}`);
// Create placeholder journal and task
const placeholderJournal = await prisma.journal.create({
data: { userId, date, content: 'Generating...', entryCount: entries.length },
data: { userId, date, content: 'Generating...', eventCount: events.length },
});
const task = await prisma.task.create({
@@ -376,11 +395,14 @@ app.post('/api/v1/journal/generate/:date', async (c) => {
try {
console.log(`[Journal Generate] Using provider: ${provider}`);
const model = providerConfig.model || settings?.aiModel;
const baseUrl = providerConfig.baseUrl || settings?.aiBaseUrl;
const aiProvider = createAIProvider({
provider: provider as 'openai' | 'anthropic' | 'ollama' | 'lmstudio' | 'groq',
apiKey: settings?.aiApiKey || '',
model: settings?.aiModel || undefined,
baseUrl: (provider === 'ollama' || provider === 'lmstudio') ? settings?.aiBaseUrl || undefined : undefined,
apiKey: apiKey,
model: model || undefined,
baseUrl: (provider === 'ollama' || provider === 'lmstudio') ? baseUrl : undefined,
});
console.log(`[Journal Generate] AI Provider created: ${aiProvider.provider}`);

View File

@@ -7,7 +7,7 @@ daysRoutes.get('/', async (c) => {
const userId = c.get('userId');
const prisma = c.get('prisma');
const days = await prisma.entry.groupBy({
const days = await prisma.event.groupBy({
by: ['date'],
where: { userId },
_count: { id: true },
@@ -16,14 +16,14 @@ daysRoutes.get('/', async (c) => {
const journals = await prisma.journal.findMany({
where: { userId },
select: { date: true, generatedAt: true, entryCount: true },
select: { date: true, generatedAt: true, eventCount: true },
});
const journalMap = new Map(journals.map(j => [j.date, j]));
const result = days.map(day => ({
date: day.date,
entryCount: day._count.id,
eventCount: day._count.id,
hasJournal: journalMap.has(day.date),
journalGeneratedAt: journalMap.get(day.date)?.generatedAt,
}));
@@ -41,8 +41,8 @@ daysRoutes.get('/:date', async (c) => {
return c.json({ data: null, error: { code: 'VALIDATION_ERROR', message: 'Invalid date format. Use YYYY-MM-DD' } }, 400);
}
const [entries, journal] = await Promise.all([
prisma.entry.findMany({
const [events, journal] = await Promise.all([
prisma.event.findMany({
where: { userId, date },
orderBy: { createdAt: 'asc' },
}),
@@ -51,7 +51,7 @@ daysRoutes.get('/:date', async (c) => {
}),
]);
return c.json({ data: { date, entries, journal }, error: null });
return c.json({ data: { date, events, journal }, error: null });
});
daysRoutes.delete('/:date', async (c) => {
@@ -60,9 +60,29 @@ daysRoutes.delete('/:date', async (c) => {
const prisma = c.get('prisma');
await prisma.$transaction([
prisma.entry.deleteMany({ where: { userId, date } }),
prisma.event.deleteMany({ where: { userId, date } }),
prisma.journal.deleteMany({ where: { userId, date } }),
]);
return c.json({ data: { deleted: true }, error: null });
});
daysRoutes.delete('/:date/journal', async (c) => {
const userId = c.get('userId');
const { date } = c.req.param();
const prisma = c.get('prisma');
const journal = await prisma.journal.findUnique({
where: { userId_date: { userId, date } },
});
if (!journal) {
return c.json({ data: null, error: { code: 'NOT_FOUND', message: 'Journal not found' } }, 404);
}
await prisma.journal.delete({
where: { id: journal.id },
});
return c.json({ data: { deleted: true }, error: null });
});

View File

@@ -1,9 +1,9 @@
import { Hono } from 'hono';
import { HonoEnv } from '../lib/types';
export const entriesRoutes = new Hono<HonoEnv>();
export const eventsRoutes = new Hono<HonoEnv>();
entriesRoutes.post('/', async (c) => {
eventsRoutes.post('/', async (c) => {
const userId = c.get('userId');
const prisma = c.get('prisma');
const mediaDir = c.env.MEDIA_DIR || './data/media';
@@ -15,12 +15,12 @@ entriesRoutes.post('/', async (c) => {
return c.json({ data: null, error: { code: 'VALIDATION_ERROR', message: 'date, type, and content are required' } }, 400);
}
const validTypes = ['text', 'voice', 'photo', 'health', 'location'];
const validTypes = ['event', 'text', 'photo', 'voice', 'health'];
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({
const event = await prisma.event.create({
data: {
userId,
date,
@@ -30,26 +30,26 @@ entriesRoutes.post('/', async (c) => {
},
});
return c.json({ data: entry, error: null }, 201);
return c.json({ data: event, error: null }, 201);
});
entriesRoutes.get('/:id', async (c) => {
eventsRoutes.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({
const event = await prisma.event.findFirst({
where: { id, userId },
});
if (!entry) {
return c.json({ data: null, error: { code: 'NOT_FOUND', message: 'Entry not found' } }, 404);
if (!event) {
return c.json({ data: null, error: { code: 'NOT_FOUND', message: 'Event not found' } }, 404);
}
return c.json({ data: entry, error: null });
return c.json({ data: event, error: null });
});
entriesRoutes.put('/:id', async (c) => {
eventsRoutes.put('/:id', async (c) => {
const userId = c.get('userId');
const { id } = c.req.param();
const prisma = c.get('prisma');
@@ -57,20 +57,20 @@ entriesRoutes.put('/:id', async (c) => {
const body = await c.req.json();
const { content, metadata } = body;
const existing = await prisma.entry.findFirst({
const existing = await prisma.event.findFirst({
where: { id, userId },
});
if (!existing) {
return c.json({ data: null, error: { code: 'NOT_FOUND', message: 'Entry not found' } }, 404);
return c.json({ data: null, error: { code: 'NOT_FOUND', message: 'Event 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);
return c.json({ data: null, error: { code: 'EVENT_IMMUTABLE', message: 'Cannot edit event: diary already generated. Delete the diary first.' } }, 400);
}
const entry = await prisma.entry.update({
const event = await prisma.event.update({
where: { id },
data: {
content: content ?? existing.content,
@@ -78,44 +78,44 @@ entriesRoutes.put('/:id', async (c) => {
},
});
return c.json({ data: entry, error: null });
return c.json({ data: event, error: null });
});
entriesRoutes.delete('/:id', async (c) => {
eventsRoutes.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({
const existing = await prisma.event.findFirst({
where: { id, userId },
});
if (!existing) {
return c.json({ data: null, error: { code: 'NOT_FOUND', message: 'Entry not found' } }, 404);
return c.json({ data: null, error: { code: 'NOT_FOUND', message: 'Event 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);
return c.json({ data: null, error: { code: 'EVENT_IMMUTABLE', message: 'Cannot delete event: diary already generated. Delete the diary first.' } }, 400);
}
await prisma.entry.delete({ where: { id } });
await prisma.event.delete({ where: { id } });
return c.json({ data: { deleted: true }, error: null });
});
entriesRoutes.post('/:id/photo', async (c) => {
eventsRoutes.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({
const event = await prisma.event.findFirst({
where: { id, userId },
});
if (!entry) {
return c.json({ data: null, error: { code: 'NOT_FOUND', message: 'Entry not found' } }, 404);
if (!event) {
return c.json({ data: null, error: { code: 'NOT_FOUND', message: 'Event not found' } }, 400);
}
const body = await c.req.parseBody();
@@ -127,12 +127,12 @@ entriesRoutes.post('/:id/photo', async (c) => {
const ext = file.name.split('.').pop() || 'jpg';
const fileName = `${id}.${ext}`;
const userMediaDir = `${mediaDir}/${userId}/${entry.date}`;
const userMediaDir = `${mediaDir}/${userId}/${event.date}`;
const filePath = `${userMediaDir}/${fileName}`;
await Bun.write(filePath, file);
await prisma.entry.update({
await prisma.event.update({
where: { id },
data: { mediaPath: filePath },
});
@@ -140,18 +140,18 @@ entriesRoutes.post('/:id/photo', async (c) => {
return c.json({ data: { mediaPath: filePath }, error: null }, 201);
});
entriesRoutes.post('/:id/voice', async (c) => {
eventsRoutes.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({
const event = await prisma.event.findFirst({
where: { id, userId },
});
if (!entry) {
return c.json({ data: null, error: { code: 'NOT_FOUND', message: 'Entry not found' } }, 404);
if (!event) {
return c.json({ data: null, error: { code: 'NOT_FOUND', message: 'Event not found' } }, 400);
}
const body = await c.req.parseBody();
@@ -162,12 +162,12 @@ entriesRoutes.post('/:id/voice', async (c) => {
}
const fileName = `${id}.webm`;
const userMediaDir = `${mediaDir}/${userId}/${entry.date}`;
const userMediaDir = `${mediaDir}/${userId}/${event.date}`;
const filePath = `${userMediaDir}/${fileName}`;
await Bun.write(filePath, file);
await prisma.entry.update({
await prisma.event.update({
where: { id },
data: { mediaPath: filePath },
});

View File

@@ -9,8 +9,8 @@ journalRoutes.post('/generate/:date', async (c) => {
const { date } = c.req.param();
const prisma = c.get('prisma');
const [entries, settings] = await Promise.all([
prisma.entry.findMany({
const [events, settings] = await Promise.all([
prisma.event.findMany({
where: { userId, date },
orderBy: { createdAt: 'asc' },
}),
@@ -19,8 +19,8 @@ journalRoutes.post('/generate/:date', async (c) => {
}),
]);
if (entries.length === 0) {
return c.json({ data: null, error: { code: 'NO_ENTRIES', message: 'No entries found for this date' } }, 400);
if (events.length === 0) {
return c.json({ data: null, error: { code: 'NO_EVENTS', message: 'No events found for this date' } }, 400);
}
if (!settings?.aiApiKey) {
@@ -34,11 +34,11 @@ journalRoutes.post('/generate/:date', async (c) => {
baseUrl: settings.aiBaseUrl,
});
const entriesText = entries.map(entry => {
let text = `[${entry.type.toUpperCase()}] ${entry.createdAt.toISOString()}\n${entry.content}`;
if (entry.metadata) {
const eventsText = events.map(event => {
let text = `[${event.type.toUpperCase()}] ${event.createdAt.toISOString()}\n${event.content}`;
if (event.metadata) {
try {
const meta = JSON.parse(entry.metadata);
const meta = JSON.parse(event.metadata);
if (meta.location) text += `\nLocation: ${meta.location.lat}, ${meta.location.lng}`;
if (meta.duration) text += `\nDuration: ${meta.duration}s`;
} catch {}
@@ -46,17 +46,17 @@ journalRoutes.post('/generate/:date', async (c) => {
return text;
}).join('\n\n');
const prompt = `The following entries were captured throughout the day (${date}). Write a thoughtful, reflective journal entry that:
const prompt = `The following events were captured throughout the day (${date}). Write a thoughtful, reflective diary page that:
1. Summarizes the key moments and activities
2. Reflects on any patterns, feelings, or insights
3. Ends with a forward-looking thought
Use a warm, personal tone. The journal should flow naturally as prose.
ENTRIES:
${entriesText}
EVENTS:
${eventsText}
JOURNAL:`;
DIARY PAGE:`;
try {
const content = await provider.generate(prompt, settings.journalPrompt);
@@ -67,11 +67,11 @@ JOURNAL:`;
userId,
date,
content,
entryCount: entries.length,
eventCount: events.length,
},
update: {
content,
entryCount: entries.length,
eventCount: events.length,
generatedAt: new Date(),
},
});

View File

@@ -52,6 +52,15 @@ function Navbar() {
);
}
function Footer() {
const { resolvedTheme } = useTheme();
return (
<footer className={`py-6 text-center text-sm text-slate-500 ${resolvedTheme === 'dark' ? 'border-t border-slate-800' : 'border-t border-slate-200'}`}>
<p>DearDiary.io Self-hosted AI-powered journaling · <a href="https://github.com/lotherk/deardiary" className="hover:text-purple-400 transition">GitHub</a> · <a href="https://deardiary.io" className="hover:text-purple-400 transition">deardiary.io</a> · MIT License · © 2024 Konrad Lother</p>
</footer>
);
}
function App() {
const [isAuthenticated, setIsAuthenticated] = useState<boolean | null>(null);
const { resolvedTheme } = useTheme();
@@ -97,6 +106,7 @@ function App() {
} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
<Footer />
</div>
</BrowserRouter>
);

View File

@@ -5,7 +5,7 @@ interface Props {
}
export default function EntryInput({ onSubmit }: Props) {
const [type, setType] = useState('text');
const [type, setType] = useState('event');
const [content, setContent] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
@@ -76,10 +76,10 @@ export default function EntryInput({ onSubmit }: Props) {
<div className="flex gap-2 mb-2">
<button
type="button"
onClick={() => setType('text')}
className={`px-3 py-1 rounded text-sm ${type === 'text' ? 'bg-slate-700' : 'bg-slate-800'}`}
onClick={() => setType('event')}
className={`px-3 py-1 rounded text-sm ${type === 'event' ? 'bg-slate-700' : 'bg-slate-800'}`}
>
Text
Event
</button>
<button
type="button"
@@ -104,13 +104,13 @@ export default function EntryInput({ onSubmit }: Props) {
</button>
</div>
{type === 'text' && (
{(type === 'text' || type === 'event') && (
<div className="flex gap-2">
<input
type="text"
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="What's on your mind?"
placeholder="Log an event..."
className="flex-1 px-4 py-3 bg-slate-800 rounded-lg border border-slate-700 focus:border-blue-500 focus:outline-none"
autoFocus
/>

View File

@@ -1,62 +1,40 @@
import type { Entry } from '../lib/api';
import type { Event } from '../lib/api';
interface Props {
entries: Entry[];
events: Event[];
onDelete: (id: string) => void;
readOnly?: boolean;
}
export default function EntryList({ entries, onDelete, readOnly }: Props) {
export default function EntryList({ events, onDelete, readOnly }: Props) {
const formatTime = (dateStr: string) => {
return new Date(dateStr).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' });
};
const getTypeIcon = (type: string) => {
switch (type) {
case 'text': return '📝';
case 'photo': return '📷';
case 'voice': return '🎤';
case 'health': return '❤️';
case 'location': return '📍';
default: return '📌';
}
};
const getTypeColor = (type: string) => {
switch (type) {
case 'text': return 'border-l-blue-500';
case 'photo': return 'border-l-yellow-500';
case 'voice': return 'border-l-purple-500';
case 'health': return 'border-l-red-500';
case 'location': return 'border-l-green-500';
default: return 'border-l-slate-500';
}
};
return (
<div className="space-y-3">
{readOnly && (
<div className="bg-amber-500/10 border border-amber-500/30 rounded-lg p-3 text-sm text-amber-400">
🔒 Entries are locked because a journal has been generated. Delete the journal to edit entries.
🔒 Events are locked because a diary page has been written. Delete the diary page to edit events.
</div>
)}
{entries.map((entry) => (
{events.map((event) => (
<div
key={entry.id}
className={`bg-slate-900 rounded-lg p-4 border border-slate-800 border-l-4 ${getTypeColor(entry.type)}`}
key={event.id}
className="bg-slate-900 rounded-lg p-4 border border-slate-800 border-l-4 border-l-blue-500"
>
<div className="flex items-start justify-between gap-4">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<span className="text-lg">{getTypeIcon(entry.type)}</span>
<span className="text-xs text-slate-500">{formatTime(entry.createdAt)}</span>
<span className="text-lg">📝</span>
<span className="text-xs text-slate-500">{formatTime(event.createdAt)}</span>
</div>
<p className="text-slate-200">{entry.content}</p>
{entry.metadata && (
<p className="text-slate-200">{event.content}</p>
{event.metadata && (
<div className="mt-2 text-xs text-slate-500">
{(() => {
try {
const meta = JSON.parse(entry.metadata);
const meta = JSON.parse(event.metadata);
return meta.location ? (
<span>📍 {meta.location.lat?.toFixed(4)}, {meta.location.lng?.toFixed(4)}</span>
) : meta.duration ? (
@@ -71,7 +49,7 @@ export default function EntryList({ entries, onDelete, readOnly }: Props) {
</div>
{!readOnly && (
<button
onClick={() => onDelete(entry.id)}
onClick={() => onDelete(event.id)}
className="text-slate-500 hover:text-red-400 text-sm transition"
>
×

View File

@@ -68,30 +68,34 @@ class ApiClient {
}
async getDays() {
return this.request<Array<{ date: string; entryCount: number; hasJournal: boolean }>>('GET', '/days');
return this.request<Array<{ date: string; eventCount: number; hasJournal: boolean }>>('GET', '/days');
}
async getDay(date: string) {
return this.request<{ date: string; entries: Entry[]; journal: Journal | null }>('GET', `/days/${date}`);
return this.request<{ date: string; events: Event[]; journal: Journal | null }>('GET', `/days/${date}`);
}
async deleteDay(date: string) {
return this.request<{ deleted: boolean }>('DELETE', `/days/${date}`);
}
async createEntry(date: string, type: string, content: string, metadata?: object) {
return this.request<Entry>('POST', '/entries', { date, type, content, metadata });
async createEvent(date: string, type: string, content: string, metadata?: object) {
return this.request<Event>('POST', '/events', { date, type, content, metadata });
}
async updateEntry(id: string, content: string, metadata?: object) {
return this.request<Entry>('PUT', `/entries/${id}`, { content, metadata });
async updateEvent(id: string, content: string, metadata?: object) {
return this.request<Event>('PUT', `/events/${id}`, { content, metadata });
}
async deleteEntry(id: string) {
return this.request<{ deleted: boolean }>('DELETE', `/entries/${id}`);
async deleteEvent(id: string) {
return this.request<{ deleted: boolean }>('DELETE', `/events/${id}`);
}
async uploadPhoto(entryId: string, file: File) {
async deleteJournal(date: string) {
return this.request<{ deleted: boolean }>('DELETE', `/journal/${date}`);
}
async uploadPhoto(eventId: string, file: File) {
const formData = new FormData();
formData.append('file', file);
@@ -100,7 +104,7 @@ class ApiClient {
headers['Authorization'] = `Bearer ${this.getApiKey()}`;
}
const response = await fetch(`${API_BASE}/entries/${entryId}/photo`, {
const response = await fetch(`${API_BASE}/events/${eventId}/photo`, {
method: 'POST',
headers,
body: formData,
@@ -108,7 +112,7 @@ class ApiClient {
return response.json() as Promise<ApiResponse<{ mediaPath: string }>>;
}
async uploadVoice(entryId: string, file: File) {
async uploadVoice(eventId: string, file: File) {
const formData = new FormData();
formData.append('file', file);
@@ -117,7 +121,7 @@ class ApiClient {
headers['Authorization'] = `Bearer ${this.getApiKey()}`;
}
const response = await fetch(`${API_BASE}/entries/${entryId}/voice`, {
const response = await fetch(`${API_BASE}/events/${eventId}/voice`, {
method: 'POST',
headers,
body: formData,
@@ -150,10 +154,10 @@ class ApiClient {
}
}
export interface Entry {
export interface Event {
id: string;
date: string;
type: 'text' | 'voice' | 'photo' | 'health' | 'location';
type: string;
content: string;
mediaPath?: string;
metadata?: string;
@@ -164,7 +168,7 @@ export interface Journal {
id: string;
date: string;
content: string;
entryCount: number;
eventCount: number;
generatedAt: string;
}

View File

@@ -1,54 +1,54 @@
import { useState, useEffect } from 'react';
import { useParams } from 'react-router-dom';
import { api, Entry } from '../lib/api';
import { api, Event } from '../lib/api';
import EntryInput from '../components/EntryInput';
import EntryList from '../components/EntryList';
export default function Day() {
const { date } = useParams<{ date: string }>();
const [entries, setEntries] = useState<Entry[]>([]);
const [events, setEvents] = useState<Event[]>([]);
const [loading, setLoading] = useState(true);
const [hasJournal, setHasJournal] = useState(false);
const [deleteError, setDeleteError] = useState<string | null>(null);
useEffect(() => {
if (date) loadEntries();
if (date) loadEvents();
}, [date]);
const loadEntries = async () => {
const loadEvents = async () => {
if (!date) return;
setLoading(true);
const res = await api.getDay(date);
if (res.data) {
setEntries(res.data.entries);
setEvents(res.data.events);
setHasJournal(!!res.data.journal);
}
setLoading(false);
};
const handleAddEntry = async (type: string, content: string, metadata?: object) => {
const handleAddEvent = async (type: string, content: string, metadata?: object) => {
if (!date) return { error: { message: 'No date' } };
const res = await api.createEntry(date, type, content, metadata);
const res = await api.createEvent(date, type, content, metadata);
if (res.data) {
setEntries((prev) => [...prev, res.data!]);
setEvents((prev) => [...prev, res.data!]);
}
return res;
};
const handleDeleteEntry = async (id: string) => {
const handleDeleteEvent = async (id: string) => {
setDeleteError(null);
const res = await api.deleteEntry(id);
const res = await api.deleteEvent(id);
if (res.data) {
setEntries((prev) => prev.filter((e) => e.id !== id));
} else if (res.error?.code === 'ENTRY_IMMUTABLE') {
setEvents((prev) => prev.filter((e) => e.id !== id));
} else if (res.error?.code === 'EVENT_IMMUTABLE') {
setDeleteError(res.error.message);
}
};
const handleDeleteJournal = async () => {
if (!date) return;
if (!confirm('Delete journal? This will unlock entries for editing.')) return;
const res = await api.deleteDay(date);
if (!confirm('Delete diary page? This will unlock events for editing.')) return;
const res = await api.deleteJournal(date);
if (res.data) {
setHasJournal(false);
}
@@ -74,10 +74,10 @@ export default function Day() {
onClick={handleDeleteJournal}
className="px-3 py-2 bg-red-600/20 hover:bg-red-600/30 text-red-400 rounded-lg text-sm transition"
>
Delete Journal
Delete Diary
</button>
<a href={`/journal/${date}`} className="px-4 py-2 bg-purple-600 hover:bg-purple-700 rounded-lg font-medium transition">
View Journal
View Diary
</a>
</div>
)}
@@ -89,16 +89,16 @@ export default function Day() {
</div>
)}
<EntryInput onSubmit={handleAddEntry} />
<EntryInput onSubmit={handleAddEvent} />
{loading ? (
<div className="text-center py-12 text-slate-400">Loading...</div>
) : entries.length === 0 ? (
) : events.length === 0 ? (
<div className="text-center py-12">
<p className="text-slate-400">No entries for this day</p>
<p className="text-slate-400">No events for this day</p>
</div>
) : (
<EntryList entries={entries} onDelete={handleDeleteEntry} readOnly={hasJournal} />
<EntryList events={events} onDelete={handleDeleteEvent} readOnly={hasJournal} />
)}
</div>
);

View File

@@ -3,7 +3,7 @@ import { api } from '../lib/api';
interface DayInfo {
date: string;
entryCount: number;
eventCount: number;
hasJournal: boolean;
}
@@ -30,7 +30,7 @@ export default function History() {
};
const handleDelete = async (date: string) => {
if (confirm('Delete all entries for this day?')) {
if (confirm('Delete all events for this day?')) {
await api.deleteDay(date);
loadDays();
}
@@ -59,11 +59,11 @@ export default function History() {
{formatDate(day.date)}
</a>
<span className="text-slate-400 text-sm">
{day.entryCount} {day.entryCount === 1 ? 'entry' : 'entries'}
{day.eventCount} {day.eventCount === 1 ? 'event' : 'events'}
</span>
{day.hasJournal && (
<a href={`/journal/${day.date}`} className="text-purple-400 text-sm hover:text-purple-300">
Journal
Diary
</a>
)}
</div>

View File

@@ -1,47 +1,56 @@
import { useState, useEffect } from 'react';
import { api, Entry } from '../lib/api';
import { useNavigate } from 'react-router-dom';
import { api, Event } from '../lib/api';
import EntryInput from '../components/EntryInput';
import EntryList from '../components/EntryList';
export default function Home() {
const [entries, setEntries] = useState<Entry[]>([]);
const [events, setEvents] = useState<Event[]>([]);
const [loading, setLoading] = useState(true);
const [generating, setGenerating] = useState(false);
const [error, setError] = useState<string | null>(null);
const navigate = useNavigate();
const today = new Date().toISOString().split('T')[0];
useEffect(() => {
loadEntries();
loadEvents();
}, []);
const loadEntries = async () => {
const loadEvents = async () => {
setLoading(true);
const res = await api.getDay(today);
if (res.data) {
setEntries(res.data.entries);
setEvents(res.data.events);
}
setLoading(false);
};
const handleAddEntry = async (type: string, content: string, metadata?: object) => {
const res = await api.createEntry(today, type, content, metadata);
const handleAddEvent = async (type: string, content: string, metadata?: object) => {
const res = await api.createEvent(today, type, content, metadata);
if (res.data) {
setEntries((prev) => [...prev, res.data!]);
setEvents((prev) => [...prev, res.data!]);
}
return res;
};
const handleDeleteEntry = async (id: string) => {
const res = await api.deleteEntry(id);
const handleDeleteEvent = async (id: string) => {
const res = await api.deleteEvent(id);
if (res.data) {
setEntries((prev) => prev.filter((e) => e.id !== id));
setEvents((prev) => prev.filter((e) => e.id !== id));
}
};
const handleGenerateJournal = async () => {
setGenerating(true);
await api.generateJournal(today);
setError(null);
const res = await api.generateJournal(today);
setGenerating(false);
if (res.error) {
setError(res.error.message);
} else {
navigate(`/journal/${today}`);
}
};
return (
@@ -53,30 +62,36 @@ export default function Home() {
</div>
<button
onClick={handleGenerateJournal}
disabled={generating || entries.length === 0}
disabled={generating || events.length === 0}
className="px-4 py-2 bg-purple-600 hover:bg-purple-700 rounded-lg font-medium transition disabled:opacity-50"
>
{generating ? 'Generating...' : 'Generate Journal'}
{generating ? 'Generating...' : 'Generate Diary Page'}
</button>
</div>
<EntryInput onSubmit={handleAddEntry} />
{error && (
<div className="mb-4 p-4 bg-red-500/20 border border-red-500/30 rounded-lg text-red-400">
{error}
</div>
)}
<EntryInput onSubmit={handleAddEvent} />
{loading ? (
<div className="text-center py-12 text-slate-400">Loading...</div>
) : entries.length === 0 ? (
) : events.length === 0 ? (
<div className="text-center py-12">
<p className="text-slate-400 mb-2">No entries yet today</p>
<p className="text-slate-400 mb-2">No events yet today</p>
<p className="text-slate-500 text-sm">Start capturing your day above</p>
</div>
) : (
<EntryList entries={entries} onDelete={handleDeleteEntry} />
<EntryList events={events} onDelete={handleDeleteEvent} />
)}
{entries.length > 0 && (
{events.length > 0 && (
<div className="mt-6 text-center">
<a href={`/journal/${today}`} className="text-purple-400 hover:text-purple-300 text-sm">
View journal
View diary page
</a>
</div>
)}

View File

@@ -197,7 +197,7 @@ export default function JournalPage() {
<div className="max-w-4xl mx-auto p-4">
<div className="mb-6">
<a href={`/day/${date}`} className="text-slate-400 hover:text-white text-sm mb-1 inline-block"> Back to day</a>
<h1 className="text-2xl font-bold">Journal</h1>
<h1 className="text-2xl font-bold">Diary Page</h1>
<p className="text-slate-400">{formatDate(date)}</p>
</div>
@@ -205,19 +205,19 @@ export default function JournalPage() {
<div className="text-center py-12 text-slate-400">Loading...</div>
) : !journal || journal.content === 'Generating...' ? (
<div className="text-center py-12">
<p className="text-slate-400 mb-4">No journal generated yet</p>
<p className="text-slate-400 mb-4">No diary page written yet</p>
<button
onClick={handleGenerate}
disabled={generating}
className="px-6 py-3 bg-purple-600 hover:bg-purple-700 rounded-lg font-medium transition disabled:opacity-50"
>
Generate Journal
Generate Diary Page
</button>
</div>
) : (
<div>
<div className="text-sm text-slate-400 mb-4">
Generated {new Date(journal.generatedAt).toLocaleString()} {journal.entryCount} entries
Generated {new Date(journal.generatedAt).toLocaleString()} {journal.eventCount} events
</div>
<div className="prose prose-invert prose-slate max-w-none">
<div className="bg-slate-900 rounded-xl p-6 border border-slate-800 whitespace-pre-wrap leading-relaxed">
@@ -230,7 +230,7 @@ export default function JournalPage() {
disabled={generating}
className="px-4 py-2 bg-slate-800 hover:bg-slate-700 rounded-lg text-sm transition disabled:opacity-50"
>
Regenerate
Rewrite
</button>
<a
href={`/tasks/${date}`}

View File

@@ -28,7 +28,7 @@ const DEFAULT_BASE_URLS: Record<string, string> = {
export default function SettingsPage() {
const [settings, setSettings] = useState<FullSettings>({
aiProvider: 'groq',
aiModel: 'llama-3.3-70b-versatile',
aiModel: '',
journalPrompt: '',
language: 'en',
timezone: 'UTC',
@@ -60,6 +60,11 @@ export default function SettingsPage() {
if (!data.providerSettings) {
data.providerSettings = {};
}
const provider = data.aiProvider || 'groq';
const providerData = data.providerSettings[provider] || {};
data.aiApiKey = providerData.apiKey;
data.aiModel = providerData.model || '';
data.aiBaseUrl = providerData.baseUrl;
setSettings(data);
}
setLoading(false);
@@ -67,22 +72,31 @@ export default function SettingsPage() {
const getCurrentProviderSettings = (): ProviderSettings => {
const provider = settings.aiProvider || 'groq';
return settings.providerSettings?.[provider] || {
apiKey: settings.aiApiKey,
model: settings.aiModel,
baseUrl: settings.aiBaseUrl,
const providerSettings = settings.providerSettings?.[provider];
return {
apiKey: providerSettings?.apiKey || settings.aiApiKey,
model: providerSettings?.model || settings.aiModel,
baseUrl: providerSettings?.baseUrl || settings.aiBaseUrl,
};
};
const updateProviderSettings = (updates: Partial<ProviderSettings>) => {
const provider = settings.aiProvider || 'groq';
const current = getCurrentProviderSettings();
const merged = { ...current, ...updates };
const newProviderSettings = {
...settings.providerSettings,
[provider]: { ...current, ...updates },
[provider]: {
apiKey: merged.apiKey,
model: merged.model || undefined,
baseUrl: merged.baseUrl || undefined,
},
};
setSettings({
...settings,
aiApiKey: merged.apiKey || settings.aiApiKey,
aiModel: merged.model || settings.aiModel,
aiBaseUrl: merged.baseUrl || settings.aiBaseUrl,
providerSettings: newProviderSettings,
aiProvider: provider,
});
@@ -122,7 +136,7 @@ export default function SettingsPage() {
};
await api.updateSettings({
...settings,
aiProvider: settings.aiProvider,
providerSettings: newProviderSettings,
});
setSaving(false);

View File

@@ -56,7 +56,7 @@ export default function TasksPage() {
onClick={() => navigate(`/journal/${date}`)}
className="text-slate-400 hover:text-white text-sm mb-1"
>
Back to journal
Back to diary
</button>
<h1 className="text-2xl font-bold">Generation Tasks</h1>
<p className="text-slate-400">{formatDate(date)}</p>
@@ -71,7 +71,7 @@ export default function TasksPage() {
onClick={() => navigate(`/journal/${date}`)}
className="px-4 py-2 bg-purple-600 hover:bg-purple-700 rounded-lg text-sm"
>
Generate Journal
Generate Diary Page
</button>
</div>
) : (

View File

@@ -1,40 +1,9 @@
#!/bin/sh
set -e
# Check if database exists and has data
if [ -f /data/deardiary.db ]; then
# Database exists - ensure migration tracking exists
bun -e "
const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient({ datasourceUrl: 'file:/data/deardiary.db' });
async function main() {
try {
const tables = await prisma.\$queryRaw\`SELECT name FROM sqlite_master WHERE type='table' AND name='_prisma_migrations'\`;
if (tables.length === 0) {
await prisma.\$executeRaw\`
CREATE TABLE _prisma_migrations (
id TEXT PRIMARY KEY,
checksum TEXT NOT NULL,
finished_at DATETIME,
migration_name TEXT NOT NULL,
logs TEXT,
rolled_back_at DATETIME,
started_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
applied_steps_count INTEGER NOT NULL DEFAULT 0
)
\`;
await prisma.\$executeRaw\`INSERT INTO _prisma_migrations (id, checksum, finished_at, migration_name, applied_steps_count) VALUES (lower(hex(randomblob(16))), 'baseline', datetime('now'), '00000000000000_init', 1)\`;
console.log('Migration table created');
}
} catch (e) {
console.log('Migration check done');
}
await prisma.\$disconnect();
}
main();
"
fi
# Push schema to database on startup
echo "Setting up database..."
bunx prisma db push --skip-generate
echo "Starting server..."
nginx -g 'daemon off;' &