148 lines
4.4 KiB
TypeScript
148 lines
4.4 KiB
TypeScript
import { OpenAPIHono, createRoute, z } from '@hono/zod-openapi';
|
|
import { db } from '../db/connection.js';
|
|
import { contexts, topics, historyEntries } from '../db/schema.js';
|
|
import { and, eq, sql, max } from 'drizzle-orm';
|
|
import { isLocked } from '../lib/ai-export-service.js';
|
|
import { ensureDailyLog } from '../lib/sync-service.js';
|
|
import type { AuthEnv } from '../middleware/auth.js';
|
|
import { randomUUID } from 'crypto';
|
|
|
|
const JOURNAL_TOPIC_ID = 'daily-log-journal';
|
|
const MAX_TITLE_LENGTH = 22;
|
|
|
|
function now(): string {
|
|
return new Date().toISOString();
|
|
}
|
|
|
|
function normalizeTitle(title: string, body: string): { title: string; text: string } {
|
|
if (title.length <= MAX_TITLE_LENGTH) {
|
|
const text = body.trim() ? `${title}\n\n${body}` : title;
|
|
return { title, text };
|
|
}
|
|
const short = title.slice(0, MAX_TITLE_LENGTH) + '…';
|
|
const fullBody = body.trim() ? `${title}\n\n${body}` : title;
|
|
return { title: short, text: `${short}\n\n${fullBody}` };
|
|
}
|
|
|
|
async function ensureJournalTopic(userId: string): Promise<void> {
|
|
const existing = await db.select({ id: topics.id }).from(topics)
|
|
.where(and(eq(topics.id, JOURNAL_TOPIC_ID), eq(topics.userId, userId)))
|
|
.get();
|
|
if (existing) return;
|
|
|
|
await db.insert(topics).values({
|
|
id: JOURNAL_TOPIC_ID,
|
|
userId,
|
|
contextId: 'daily-log',
|
|
title: '__journal__',
|
|
status: 'active',
|
|
snoozeUntil: null,
|
|
sortOrder: -1,
|
|
isNew: false,
|
|
updatedAt: now(),
|
|
deletedAt: null,
|
|
version: 1,
|
|
});
|
|
}
|
|
|
|
const DailyLogBody = z.object({
|
|
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Must be YYYY-MM-DD'),
|
|
scope: z.enum(['private', 'company']),
|
|
title: z.string().min(1).max(200),
|
|
body: z.string(),
|
|
});
|
|
|
|
const DailyLogResponse = z.object({
|
|
contextId: z.string(),
|
|
topicId: z.string(),
|
|
historyEntryId: z.string(),
|
|
});
|
|
|
|
const pushDailyLogRoute = createRoute({
|
|
method: 'post',
|
|
path: '/daily-log',
|
|
summary: 'Create a new daily log entry',
|
|
description: 'Pushes a new journal entry (historyEntry) into the daily-log context. Returns 409 if an entry with the same title already exists on that date.',
|
|
security: [{ BearerAuth: [] }],
|
|
request: {
|
|
body: {
|
|
content: { 'application/json': { schema: DailyLogBody } },
|
|
required: true,
|
|
},
|
|
},
|
|
responses: {
|
|
201: {
|
|
description: 'Entry created',
|
|
content: { 'application/json': { schema: DailyLogResponse } },
|
|
},
|
|
409: {
|
|
description: 'Duplicate: entry with this title already exists on this date',
|
|
content: { 'application/json': { schema: z.object({ error: z.string() }) } },
|
|
},
|
|
423: {
|
|
description: 'AI lock active',
|
|
content: { 'application/json': { schema: z.object({ error: z.string() }) } },
|
|
},
|
|
},
|
|
});
|
|
|
|
const push = new OpenAPIHono<AuthEnv>();
|
|
|
|
push.openapi(pushDailyLogRoute, async (c) => {
|
|
const { userId } = c.get('auth');
|
|
|
|
const lock = await isLocked(userId);
|
|
if (lock) return c.json({ error: 'AI lock active — unlock at /api/ai/unlock' }, 423);
|
|
|
|
await ensureDailyLog(userId);
|
|
await ensureJournalTopic(userId);
|
|
|
|
const { date, scope, title: rawTitle, body } = c.req.valid('json');
|
|
const isPrivate = scope === 'private';
|
|
const { title, text } = normalizeTitle(rawTitle, body);
|
|
|
|
// Duplicate check: same date + title as first line (plain or legacy ## prefix)
|
|
const existing = await db.select({ id: historyEntries.id })
|
|
.from(historyEntries)
|
|
.where(and(
|
|
eq(historyEntries.userId, userId),
|
|
eq(historyEntries.topicId, JOURNAL_TOPIC_ID),
|
|
eq(historyEntries.date, date),
|
|
sql`(${historyEntries.text} LIKE ${title + '%'} OR ${historyEntries.text} LIKE ${'## ' + title + '%'})`,
|
|
))
|
|
.get();
|
|
|
|
if (existing) {
|
|
return c.json({ error: `Entry with title "${title}" already exists for ${date}` }, 409);
|
|
}
|
|
|
|
const maxSortOrder = await db.select({ val: max(historyEntries.sortOrder) })
|
|
.from(historyEntries)
|
|
.where(and(eq(historyEntries.userId, userId), eq(historyEntries.topicId, JOURNAL_TOPIC_ID)))
|
|
.get();
|
|
|
|
const sortOrder = (maxSortOrder?.val ?? 0) + 1;
|
|
const id = randomUUID();
|
|
|
|
await db.insert(historyEntries).values({
|
|
id,
|
|
userId,
|
|
topicId: JOURNAL_TOPIC_ID,
|
|
date,
|
|
text,
|
|
sortOrder,
|
|
linkedContextId: null,
|
|
doneAt: null,
|
|
wiedervorlageDate: null,
|
|
wiedervorlageResolvedAt: null,
|
|
isPrivate,
|
|
updatedAt: now(),
|
|
deletedAt: null,
|
|
version: 1,
|
|
});
|
|
|
|
return c.json({ contextId: 'daily-log', topicId: JOURNAL_TOPIC_ID, historyEntryId: id }, 201);
|
|
});
|
|
|
|
export default push;
|