Initial commit: deardiary project setup
This commit is contained in:
20
backend/.env.example
Normal file
20
backend/.env.example
Normal file
@@ -0,0 +1,20 @@
|
||||
# Database connection (SQLite, PostgreSQL, or MySQL)
|
||||
DATABASE_URL="file:./data/totalrecall.db"
|
||||
|
||||
# Media storage directory
|
||||
MEDIA_DIR="./data/media"
|
||||
|
||||
# JWT secret for authentication tokens (REQUIRED in production)
|
||||
JWT_SECRET="change-this-to-a-random-string-in-production"
|
||||
|
||||
# Server port
|
||||
PORT="3000"
|
||||
|
||||
# CORS origin (use specific domain in production)
|
||||
CORS_ORIGIN="*"
|
||||
|
||||
# Example PostgreSQL connection:
|
||||
# DATABASE_URL="postgresql://postgres:password@db:5432/totalrecall"
|
||||
|
||||
# Example MySQL connection:
|
||||
# DATABASE_URL="mysql://root:password@localhost:3306/totalrecall"
|
||||
39
backend/Dockerfile
Normal file
39
backend/Dockerfile
Normal file
@@ -0,0 +1,39 @@
|
||||
# Build stage
|
||||
FROM oven/bun:1.1-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN bun install
|
||||
|
||||
COPY prisma ./prisma
|
||||
RUN bunx prisma generate
|
||||
|
||||
COPY src ./src
|
||||
|
||||
RUN bun build src/index.ts --outdir ./dist --target bun
|
||||
|
||||
# Production stage
|
||||
FROM oven/bun:1.1-alpine AS runner
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs && \
|
||||
adduser --system --uid 1001 bun
|
||||
|
||||
COPY --from=builder /app/dist ./dist
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
COPY --from=builder /app/package.json ./
|
||||
|
||||
RUN mkdir -p /data && chown -R bun:nodejs /data
|
||||
|
||||
USER bun
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1
|
||||
|
||||
CMD ["bun", "run", "src/index.ts"]
|
||||
50
backend/bun.lock
Normal file
50
backend/bun.lock
Normal file
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "totalrecall-backend",
|
||||
"dependencies": {
|
||||
"@prisma/client": "^5.22.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"hono": "^4.6.10",
|
||||
"jose": "^5.9.6",
|
||||
"nanoid": "^5.0.8",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"prisma": "^5.22.0",
|
||||
"typescript": "^5.6.3",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@prisma/client": ["@prisma/client@5.22.0", "", { "peerDependencies": { "prisma": "*" }, "optionalPeers": ["prisma"] }, "sha512-M0SVXfyHnQREBKxCgyo7sffrKttwE6R8PMq330MIUF0pTwjUhLbW84pFDlf06B27XyCR++VtjugEnIHdr07SVA=="],
|
||||
|
||||
"@prisma/debug": ["@prisma/debug@5.22.0", "", {}, "sha512-AUt44v3YJeggO2ZU5BkXI7M4hu9BF2zzH2iF2V5pyXT/lRTyWiElZ7It+bRH1EshoMRxHgpYg4VB6rCM+mG5jQ=="],
|
||||
|
||||
"@prisma/engines": ["@prisma/engines@5.22.0", "", { "dependencies": { "@prisma/debug": "5.22.0", "@prisma/engines-version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", "@prisma/fetch-engine": "5.22.0", "@prisma/get-platform": "5.22.0" } }, "sha512-UNjfslWhAt06kVL3CjkuYpHAWSO6L4kDCVPegV6itt7nD1kSJavd3vhgAEhjglLJJKEdJ7oIqDJ+yHk6qO8gPA=="],
|
||||
|
||||
"@prisma/engines-version": ["@prisma/engines-version@5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", "", {}, "sha512-2PTmxFR2yHW/eB3uqWtcgRcgAbG1rwG9ZriSvQw+nnb7c4uCr3RAcGMb6/zfE88SKlC1Nj2ziUvc96Z379mHgQ=="],
|
||||
|
||||
"@prisma/fetch-engine": ["@prisma/fetch-engine@5.22.0", "", { "dependencies": { "@prisma/debug": "5.22.0", "@prisma/engines-version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", "@prisma/get-platform": "5.22.0" } }, "sha512-bkrD/Mc2fSvkQBV5EpoFcZ87AvOgDxbG99488a5cexp5Ccny+UM6MAe/UFkUC0wLYD9+9befNOqGiIJhhq+HbA=="],
|
||||
|
||||
"@prisma/get-platform": ["@prisma/get-platform@5.22.0", "", { "dependencies": { "@prisma/debug": "5.22.0" } }, "sha512-pHhpQdr1UPFpt+zFfnPazhulaZYCUqeIcPpJViYoq9R+D/yw4fjE+CtnsnKzPYm0ddUbeXUzjGVGIRVgPDCk4Q=="],
|
||||
|
||||
"@types/bcryptjs": ["@types/bcryptjs@2.4.6", "", {}, "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ=="],
|
||||
|
||||
"bcryptjs": ["bcryptjs@2.4.3", "", {}, "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ=="],
|
||||
|
||||
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
|
||||
"hono": ["hono@4.12.9", "", {}, "sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA=="],
|
||||
|
||||
"jose": ["jose@5.10.0", "", {}, "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg=="],
|
||||
|
||||
"nanoid": ["nanoid@5.1.7", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-ua3NDgISf6jdwezAheMOk4mbE1LXjm1DfMUDMuJf4AqxLFK3ccGpgWizwa5YV7Yz9EpXwEaWoRXSb/BnV0t5dQ=="],
|
||||
|
||||
"prisma": ["prisma@5.22.0", "", { "dependencies": { "@prisma/engines": "5.22.0" }, "optionalDependencies": { "fsevents": "2.3.3" }, "bin": { "prisma": "build/index.js" } }, "sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A=="],
|
||||
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
}
|
||||
}
|
||||
26
backend/package.json
Normal file
26
backend/package.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "totalrecall-backend",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "bun --watch src/index.ts",
|
||||
"build": "bun build src/index.ts --outdir ./dist --target bun",
|
||||
"start": "bun run src/index.ts",
|
||||
"db:generate": "prisma generate",
|
||||
"db:push": "prisma db push",
|
||||
"db:migrate": "prisma migrate dev",
|
||||
"db:studio": "prisma studio"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "^5.22.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"hono": "^4.6.10",
|
||||
"jose": "^5.9.6",
|
||||
"nanoid": "^5.0.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"prisma": "^5.22.0",
|
||||
"typescript": "^5.6.3"
|
||||
}
|
||||
}
|
||||
101
backend/prisma/schema.prisma
Normal file
101
backend/prisma/schema.prisma
Normal file
@@ -0,0 +1,101 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "sqlite"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(uuid())
|
||||
email String @unique
|
||||
passwordHash String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
apiKeys ApiKey[]
|
||||
entries Entry[]
|
||||
journals Journal[]
|
||||
tasks Task[]
|
||||
settings Settings?
|
||||
}
|
||||
|
||||
model ApiKey {
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
keyHash String @unique
|
||||
name String
|
||||
lastUsedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
model Entry {
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
date String
|
||||
type String
|
||||
content String
|
||||
mediaPath String?
|
||||
metadata String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([userId, date])
|
||||
@@index([date])
|
||||
}
|
||||
|
||||
model Journal {
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
date String
|
||||
content String
|
||||
entryCount Int
|
||||
generatedAt DateTime @default(now())
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
tasks Task[]
|
||||
|
||||
@@unique([userId, date])
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
model Task {
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
journalId String
|
||||
type String @default("journal_generate")
|
||||
status String @default("pending")
|
||||
provider String
|
||||
model String?
|
||||
prompt String
|
||||
request String?
|
||||
response String?
|
||||
error String?
|
||||
createdAt DateTime @default(now())
|
||||
completedAt DateTime?
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
journal Journal @relation(fields: [journalId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([userId])
|
||||
@@index([journalId])
|
||||
}
|
||||
|
||||
model Settings {
|
||||
userId String @id
|
||||
aiProvider String @default("openai")
|
||||
aiApiKey String?
|
||||
aiModel String @default("gpt-4")
|
||||
aiBaseUrl String?
|
||||
journalPrompt String @default("You are a thoughtful journal writer. Based on the entries provided, write a reflective journal entry for this day in a warm, personal tone.")
|
||||
language String @default("en")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
588
backend/src/index.ts
Normal file
588
backend/src/index.ts
Normal file
@@ -0,0 +1,588 @@
|
||||
import { Hono } from 'hono';
|
||||
import { cors } from 'hono/cors';
|
||||
import { logger } from 'hono/logger';
|
||||
import { env } from 'hono/adapter';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { createHash, randomBytes } from 'crypto';
|
||||
import * as jose from 'jose';
|
||||
import { Prisma } from '@prisma/client';
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
const envVars = env(app);
|
||||
|
||||
app.use('*', logger());
|
||||
app.use('*', cors({
|
||||
origin: envVars.CORS_ORIGIN || '*',
|
||||
credentials: true,
|
||||
}));
|
||||
|
||||
const prisma = new PrismaClient({
|
||||
datasourceUrl: envVars.DATABASE_URL || 'file:./data/totalrecall.db',
|
||||
});
|
||||
|
||||
app.use('*', async (c, next) => {
|
||||
c.set('prisma', prisma as any);
|
||||
await next();
|
||||
});
|
||||
|
||||
const getUserId = async (c: any): Promise<string | null> => {
|
||||
const authHeader = c.req.header('Authorization');
|
||||
if (!authHeader?.startsWith('Bearer ')) return null;
|
||||
|
||||
const apiKey = authHeader.slice(7);
|
||||
const keyHash = createHash('sha256').update(apiKey).digest('hex');
|
||||
|
||||
const keyRecord = await prisma.apiKey.findUnique({
|
||||
where: { keyHash },
|
||||
include: { user: true },
|
||||
});
|
||||
|
||||
if (!keyRecord) return null;
|
||||
|
||||
await prisma.apiKey.update({
|
||||
where: { id: keyRecord.id },
|
||||
data: { lastUsedAt: new Date() },
|
||||
});
|
||||
|
||||
return keyRecord.userId;
|
||||
};
|
||||
|
||||
app.get('/health', (c) => c.json({ status: 'ok', timestamp: new Date().toISOString() }));
|
||||
|
||||
// Auth routes
|
||||
app.post('/api/v1/auth/register', async (c) => {
|
||||
const { email, password } = await c.req.json();
|
||||
|
||||
if (!email || !password) {
|
||||
return c.json({ data: null, error: { code: 'VALIDATION_ERROR', message: 'Email and password are required' } }, 400);
|
||||
}
|
||||
|
||||
if (password.length < 8) {
|
||||
return c.json({ data: null, error: { code: 'VALIDATION_ERROR', message: 'Password must be at least 8 characters' } }, 400);
|
||||
}
|
||||
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(email)) {
|
||||
return c.json({ data: null, error: { code: 'VALIDATION_ERROR', message: 'Invalid email format' } }, 400);
|
||||
}
|
||||
|
||||
const existing = await prisma.user.findUnique({ where: { email } });
|
||||
if (existing) {
|
||||
return c.json({ data: null, error: { code: 'CONFLICT', message: 'Email already registered' } }, 409);
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(password, 12);
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
email,
|
||||
passwordHash,
|
||||
settings: { create: {} },
|
||||
},
|
||||
select: { id: true, email: true, createdAt: true },
|
||||
});
|
||||
|
||||
return c.json({ data: { user }, error: null }, 201);
|
||||
});
|
||||
|
||||
app.post('/api/v1/auth/login', async (c) => {
|
||||
const { email, password } = await c.req.json();
|
||||
|
||||
if (!email || !password) {
|
||||
return c.json({ data: null, error: { code: 'VALIDATION_ERROR', message: 'Email and password are required' } }, 400);
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { email } });
|
||||
if (!user) {
|
||||
return c.json({ data: null, error: { code: 'UNAUTHORIZED', message: 'Invalid credentials' } }, 401);
|
||||
}
|
||||
|
||||
const valid = await bcrypt.compare(password, user.passwordHash);
|
||||
if (!valid) {
|
||||
return c.json({ data: null, error: { code: 'UNAUTHORIZED', message: 'Invalid credentials' } }, 401);
|
||||
}
|
||||
|
||||
const jwtSecret = envVars.JWT_SECRET || 'development-secret-change-in-production';
|
||||
const token = await new jose.SignJWT({ userId: user.id })
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
.setIssuedAt()
|
||||
.setExpirationTime('7d')
|
||||
.sign(new TextEncoder().encode(jwtSecret));
|
||||
|
||||
return c.json({ data: { token, userId: user.id }, error: null });
|
||||
});
|
||||
|
||||
app.post('/api/v1/auth/api-key', async (c) => {
|
||||
const authHeader = c.req.header('Authorization');
|
||||
if (!authHeader?.startsWith('Bearer ')) {
|
||||
return c.json({ data: null, error: { code: 'UNAUTHORIZED', message: 'Missing or invalid Authorization header' } }, 401);
|
||||
}
|
||||
|
||||
const token = authHeader.slice(7);
|
||||
const jwtSecret = envVars.JWT_SECRET || 'development-secret-change-in-production';
|
||||
|
||||
let userId: string;
|
||||
try {
|
||||
const { payload } = await jose.jwtVerify(token, new TextEncoder().encode(jwtSecret));
|
||||
userId = payload.userId as string;
|
||||
} catch {
|
||||
return c.json({ data: null, error: { code: 'UNAUTHORIZED', message: 'Invalid or expired token' } }, 401);
|
||||
}
|
||||
|
||||
const { name } = (await c.req.json()) || {};
|
||||
const apiKey = randomBytes(32).toString('hex');
|
||||
const keyHash = createHash('sha256').update(apiKey).digest('hex');
|
||||
|
||||
const keyRecord = await prisma.apiKey.create({
|
||||
data: { userId, keyHash, name: name || 'Default' },
|
||||
});
|
||||
|
||||
return c.json({ data: { apiKey, id: keyRecord.id, name: keyRecord.name }, error: null }, 201);
|
||||
});
|
||||
|
||||
// Days routes
|
||||
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({
|
||||
by: ['date'],
|
||||
where: { userId },
|
||||
_count: { id: true },
|
||||
orderBy: { date: 'desc' },
|
||||
});
|
||||
|
||||
const journals = await prisma.journal.findMany({
|
||||
where: { userId },
|
||||
select: { date: true, generatedAt: true },
|
||||
});
|
||||
|
||||
const journalMap = new Map(journals.map(j => [j.date, j]));
|
||||
|
||||
const result = days.map(day => ({
|
||||
date: day.date,
|
||||
entryCount: day._count.id,
|
||||
hasJournal: journalMap.has(day.date),
|
||||
journalGeneratedAt: journalMap.get(day.date)?.generatedAt,
|
||||
}));
|
||||
|
||||
return c.json({ data: result, error: null });
|
||||
});
|
||||
|
||||
app.get('/api/v1/days/: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 dateRegex = /^\d{4}-\d{2}-\d{2}$/;
|
||||
if (!dateRegex.test(date)) {
|
||||
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' } }),
|
||||
prisma.journal.findFirst({ where: { userId, date } }),
|
||||
]);
|
||||
|
||||
return c.json({ data: { date, entries, journal }, error: null });
|
||||
});
|
||||
|
||||
app.delete('/api/v1/days/: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();
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.entry.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) => {
|
||||
const userId = await getUserId(c);
|
||||
if (!userId) return c.json({ data: null, error: { code: 'UNAUTHORIZED', message: 'Invalid API key' } }, 401);
|
||||
|
||||
const { date, type, content, metadata } = await c.req.json();
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
app.get('/api/v1/entries/: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 } });
|
||||
|
||||
if (!entry) return c.json({ data: null, error: { code: 'NOT_FOUND', message: 'Entry not found' } }, 404);
|
||||
return c.json({ data: entry, error: null });
|
||||
});
|
||||
|
||||
app.put('/api/v1/entries/: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 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 });
|
||||
});
|
||||
|
||||
app.delete('/api/v1/entries/: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);
|
||||
|
||||
await prisma.entry.delete({ where: { id } });
|
||||
return c.json({ data: { deleted: true }, error: null });
|
||||
});
|
||||
|
||||
// Journal routes
|
||||
app.post('/api/v1/journal/generate/: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 [entries, settings] = await Promise.all([
|
||||
prisma.entry.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);
|
||||
}
|
||||
|
||||
const provider = settings?.aiProvider || 'openai';
|
||||
|
||||
if ((provider === 'openai' || provider === 'anthropic') && !settings?.aiApiKey) {
|
||||
return c.json({ data: null, error: { code: 'NO_AI_CONFIG', message: 'AI not configured. Please set up your API key in settings.' } }, 400);
|
||||
}
|
||||
|
||||
const entriesText = entries.map(entry => {
|
||||
let text = `[${entry.type.toUpperCase()}] ${entry.createdAt.toISOString()}\n${entry.content}`;
|
||||
if (entry.metadata) {
|
||||
try {
|
||||
const meta = JSON.parse(entry.metadata);
|
||||
if (meta.location) text += `\nLocation: ${meta.location.lat}, ${meta.location.lng}`;
|
||||
if (meta.duration) text += `\nDuration: ${meta.duration}s`;
|
||||
} catch {}
|
||||
}
|
||||
return text;
|
||||
}).join('\n\n');
|
||||
|
||||
const systemPrompt = settings?.journalPrompt || 'You are a thoughtful journal writer.';
|
||||
const userPrompt = `The following entries were captured throughout the day (${date}). Write a thoughtful, reflective journal entry.
|
||||
|
||||
ENTRIES:
|
||||
${entriesText}
|
||||
|
||||
JOURNAL:`;
|
||||
|
||||
// Create placeholder journal and task
|
||||
const placeholderJournal = await prisma.journal.create({
|
||||
data: { userId, date, content: 'Generating...', entryCount: entries.length },
|
||||
});
|
||||
|
||||
const task = await prisma.task.create({
|
||||
data: {
|
||||
userId,
|
||||
journalId: placeholderJournal.id,
|
||||
type: 'journal_generate',
|
||||
status: 'pending',
|
||||
provider,
|
||||
model: settings?.aiModel,
|
||||
prompt: `${systemPrompt}\n\n${userPrompt}`,
|
||||
},
|
||||
});
|
||||
|
||||
// Update journal with taskId
|
||||
await prisma.journal.update({
|
||||
where: { id: placeholderJournal.id },
|
||||
data: { id: placeholderJournal.id },
|
||||
});
|
||||
|
||||
let requestBody: any = null;
|
||||
let responseBody: any = null;
|
||||
let content = '';
|
||||
|
||||
try {
|
||||
if (provider === 'openai') {
|
||||
requestBody = {
|
||||
model: settings?.aiModel || 'gpt-4',
|
||||
messages: [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
{ role: 'user', content: userPrompt }
|
||||
],
|
||||
temperature: 0.7,
|
||||
max_tokens: 2000,
|
||||
};
|
||||
|
||||
const response = await fetch('https://api.openai.com/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${settings?.aiApiKey}`,
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
|
||||
responseBody = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`OpenAI API error: ${response.status} ${JSON.stringify(responseBody)}`);
|
||||
}
|
||||
|
||||
content = responseBody.choices?.[0]?.message?.content || '';
|
||||
|
||||
} else if (provider === 'anthropic') {
|
||||
requestBody = {
|
||||
model: settings?.aiModel || 'claude-3-sonnet-20240229',
|
||||
max_tokens: 2000,
|
||||
system: systemPrompt,
|
||||
messages: [{ role: 'user', content: userPrompt }],
|
||||
};
|
||||
|
||||
const response = await fetch('https://api.anthropic.com/v1/messages', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-api-key': settings?.aiApiKey,
|
||||
'anthropic-version': '2023-06-01',
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
|
||||
responseBody = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Anthropic API error: ${response.status} ${JSON.stringify(responseBody)}`);
|
||||
}
|
||||
|
||||
content = responseBody.content?.[0]?.text || '';
|
||||
|
||||
} else if (provider === 'ollama') {
|
||||
const baseUrl = settings?.aiBaseUrl || 'http://localhost:11434';
|
||||
requestBody = {
|
||||
model: settings?.aiModel || 'llama3.2',
|
||||
stream: false,
|
||||
messages: [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
{ role: 'user', content: userPrompt }
|
||||
],
|
||||
};
|
||||
|
||||
const response = await fetch(`${baseUrl}/api/chat`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
|
||||
responseBody = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Ollama API error: ${response.status} ${JSON.stringify(responseBody)}`);
|
||||
}
|
||||
|
||||
content = responseBody.message?.content || '';
|
||||
|
||||
} else if (provider === 'lmstudio') {
|
||||
const baseUrl = settings?.aiBaseUrl || 'http://localhost:1234/v1';
|
||||
requestBody = {
|
||||
model: settings?.aiModel || 'local-model',
|
||||
messages: [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
{ role: 'user', content: userPrompt }
|
||||
],
|
||||
temperature: 0.7,
|
||||
max_tokens: 2000,
|
||||
};
|
||||
|
||||
const response = await fetch(`${baseUrl}/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
|
||||
responseBody = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`LM Studio API error: ${response.status} ${JSON.stringify(responseBody)}`);
|
||||
}
|
||||
|
||||
content = responseBody.choices?.[0]?.message?.content || '';
|
||||
}
|
||||
|
||||
if (!content) {
|
||||
throw new Error('No content generated from AI');
|
||||
}
|
||||
|
||||
// Update task with success
|
||||
await prisma.task.update({
|
||||
where: { id: task.id },
|
||||
data: {
|
||||
status: 'completed',
|
||||
request: JSON.stringify(requestBody, null, 2),
|
||||
response: JSON.stringify(responseBody, null, 2),
|
||||
completedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
// Update journal with content
|
||||
const journal = await prisma.journal.update({
|
||||
where: { id: placeholderJournal.id },
|
||||
data: { content, generatedAt: new Date() },
|
||||
});
|
||||
|
||||
return c.json({ data: { journal, task }, error: null });
|
||||
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
|
||||
console.error('AI generation failed:', errorMessage);
|
||||
|
||||
// Update task with error
|
||||
await prisma.task.update({
|
||||
where: { id: task.id },
|
||||
data: {
|
||||
status: 'failed',
|
||||
error: errorMessage,
|
||||
completedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
// Delete placeholder journal
|
||||
await prisma.journal.delete({ where: { id: placeholderJournal.id } });
|
||||
|
||||
return c.json({ data: null, error: { code: 'AI_ERROR', message: `Failed to generate journal: ${errorMessage}` } }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/v1/journal/:date/tasks', 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: 'No journal found for this date' } }, 404);
|
||||
}
|
||||
|
||||
const tasks = await prisma.task.findMany({
|
||||
where: { journalId: journal.id },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
return c.json({ data: tasks, error: null });
|
||||
});
|
||||
|
||||
app.get('/api/v1/tasks/: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 task = await prisma.task.findFirst({
|
||||
where: { id, userId },
|
||||
});
|
||||
|
||||
if (!task) {
|
||||
return c.json({ data: null, error: { code: 'NOT_FOUND', message: 'Task not found' } }, 404);
|
||||
}
|
||||
|
||||
return c.json({ data: task, error: null });
|
||||
});
|
||||
|
||||
app.get('/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: 'No journal found for this date' } }, 404);
|
||||
return c.json({ data: journal, error: null });
|
||||
});
|
||||
|
||||
// Settings routes
|
||||
app.get('/api/v1/settings', async (c) => {
|
||||
const userId = await getUserId(c);
|
||||
if (!userId) return c.json({ data: null, error: { code: 'UNAUTHORIZED', message: 'Invalid API key' } }, 401);
|
||||
|
||||
let settings = await prisma.settings.findUnique({ where: { userId } });
|
||||
if (!settings) {
|
||||
settings = await prisma.settings.create({ data: { userId } });
|
||||
}
|
||||
|
||||
return c.json({ data: settings, error: null });
|
||||
});
|
||||
|
||||
app.put('/api/v1/settings', async (c) => {
|
||||
const userId = await getUserId(c);
|
||||
if (!userId) return c.json({ data: null, error: { code: 'UNAUTHORIZED', message: 'Invalid API key' } }, 401);
|
||||
|
||||
const body = await c.req.json();
|
||||
const { aiProvider, aiApiKey, aiModel, aiBaseUrl, journalPrompt, language } = body;
|
||||
|
||||
const data: Record<string, unknown> = {};
|
||||
if (aiProvider !== undefined) data.aiProvider = aiProvider;
|
||||
if (aiApiKey !== undefined) data.aiApiKey = aiApiKey;
|
||||
if (aiModel !== undefined) data.aiModel = aiModel || 'gpt-4';
|
||||
if (aiBaseUrl !== undefined) data.aiBaseUrl = aiBaseUrl;
|
||||
if (journalPrompt !== undefined) data.journalPrompt = journalPrompt;
|
||||
if (language !== undefined) data.language = language;
|
||||
|
||||
const settings = await prisma.settings.upsert({
|
||||
where: { userId },
|
||||
create: { userId, ...data },
|
||||
update: data,
|
||||
});
|
||||
|
||||
return c.json({ data: settings, error: null });
|
||||
});
|
||||
|
||||
app.notFound((c) => c.json({ data: null, error: { code: 'NOT_FOUND', message: 'Not found' } }, 404));
|
||||
app.onError((err, c) => {
|
||||
console.error('Unhandled error:', err);
|
||||
return c.json({ data: null, error: { code: 'INTERNAL_ERROR', message: 'Internal server error' } }, 500);
|
||||
});
|
||||
|
||||
const port = parseInt(envVars.PORT || '3000', 10);
|
||||
console.log(`Starting TotalRecall API on port ${port}`);
|
||||
|
||||
export default {
|
||||
port,
|
||||
fetch: app.fetch,
|
||||
};
|
||||
15
backend/src/lib/types.ts
Normal file
15
backend/src/lib/types.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
export interface HonoEnv {
|
||||
Variables: {
|
||||
userId: string;
|
||||
prisma: PrismaClient;
|
||||
};
|
||||
Bindings: {
|
||||
DATABASE_URL: string;
|
||||
JWT_SECRET: string;
|
||||
MEDIA_DIR: string;
|
||||
PORT: string;
|
||||
CORS_ORIGIN: string;
|
||||
};
|
||||
}
|
||||
39
backend/src/middleware/auth.ts
Normal file
39
backend/src/middleware/auth.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { Context, Next } from 'hono';
|
||||
import { HonoEnv } from '../lib/types';
|
||||
import { createHash } from 'crypto';
|
||||
|
||||
export async function authMiddleware(c: Context<HonoEnv>, next: Next) {
|
||||
const authHeader = c.req.header('Authorization');
|
||||
|
||||
if (!authHeader?.startsWith('Bearer ')) {
|
||||
return c.json({ data: null, error: { code: 'UNAUTHORIZED', message: 'Missing or invalid Authorization header' } }, 401);
|
||||
}
|
||||
|
||||
const apiKey = authHeader.slice(7);
|
||||
const keyHash = createHash('sha256').update(apiKey).digest('hex');
|
||||
|
||||
const prisma = c.get('prisma');
|
||||
|
||||
const keyRecord = await prisma.apiKey.findUnique({
|
||||
where: { keyHash },
|
||||
include: { user: true },
|
||||
});
|
||||
|
||||
if (!keyRecord) {
|
||||
return c.json({ data: null, error: { code: 'UNAUTHORIZED', message: 'Invalid API key' } }, 401);
|
||||
}
|
||||
|
||||
await prisma.apiKey.update({
|
||||
where: { id: keyRecord.id },
|
||||
data: { lastUsedAt: new Date() },
|
||||
});
|
||||
|
||||
c.set('userId', keyRecord.userId);
|
||||
await next();
|
||||
}
|
||||
|
||||
export function authenticate() {
|
||||
return async (c: Context<HonoEnv>, next: Next) => {
|
||||
await authMiddleware(c, next);
|
||||
};
|
||||
}
|
||||
115
backend/src/routes/auth.ts
Normal file
115
backend/src/routes/auth.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { Hono } from 'hono';
|
||||
import { HonoEnv } from '../lib/types';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { createHash, randomBytes } from 'crypto';
|
||||
import * as jose from 'jose';
|
||||
|
||||
function createAuthRoutes() {
|
||||
const app = new Hono<HonoEnv>();
|
||||
|
||||
authRoutes.post('/register', async (c) => {
|
||||
const { email, password } = await c.req.json();
|
||||
|
||||
if (!email || !password) {
|
||||
return c.json({ data: null, error: { code: 'VALIDATION_ERROR', message: 'Email and password are required' } }, 400);
|
||||
}
|
||||
|
||||
if (password.length < 8) {
|
||||
return c.json({ data: null, error: { code: 'VALIDATION_ERROR', message: 'Password must be at least 8 characters' } }, 400);
|
||||
}
|
||||
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(email)) {
|
||||
return c.json({ data: null, error: { code: 'VALIDATION_ERROR', message: 'Invalid email format' } }, 400);
|
||||
}
|
||||
|
||||
const prisma = c.get('prisma');
|
||||
|
||||
const existing = await prisma.user.findUnique({ where: { email } });
|
||||
if (existing) {
|
||||
return c.json({ data: null, error: { code: 'CONFLICT', message: 'Email already registered' } }, 409);
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(password, 12);
|
||||
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
email,
|
||||
passwordHash,
|
||||
settings: {
|
||||
create: {},
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
return c.json({ data: { user }, error: null }, 201);
|
||||
});
|
||||
|
||||
authRoutes.post('/login', async (c) => {
|
||||
const { email, password } = await c.req.json();
|
||||
|
||||
if (!email || !password) {
|
||||
return c.json({ data: null, error: { code: 'VALIDATION_ERROR', message: 'Email and password are required' } }, 400);
|
||||
}
|
||||
|
||||
const prisma = c.get('prisma');
|
||||
const jwtSecret = c.env.JWT_SECRET || 'development-secret-change-in-production';
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { email } });
|
||||
if (!user) {
|
||||
return c.json({ data: null, error: { code: 'UNAUTHORIZED', message: 'Invalid credentials' } }, 401);
|
||||
}
|
||||
|
||||
const valid = await bcrypt.compare(password, user.passwordHash);
|
||||
if (!valid) {
|
||||
return c.json({ data: null, error: { code: 'UNAUTHORIZED', message: 'Invalid credentials' } }, 401);
|
||||
}
|
||||
|
||||
const token = await new jose.SignJWT({ userId: user.id })
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
.setIssuedAt()
|
||||
.setExpirationTime('7d')
|
||||
.sign(new TextEncoder().encode(jwtSecret));
|
||||
|
||||
return c.json({ data: { token, userId: user.id }, error: null });
|
||||
});
|
||||
|
||||
authRoutes.post('/api-key', async (c) => {
|
||||
const authHeader = c.req.header('Authorization');
|
||||
|
||||
if (!authHeader?.startsWith('Bearer ')) {
|
||||
return c.json({ data: null, error: { code: 'UNAUTHORIZED', message: 'Missing or invalid Authorization header' } }, 401);
|
||||
}
|
||||
|
||||
const token = authHeader.slice(7);
|
||||
const jwtSecret = c.env.JWT_SECRET || 'development-secret-change-in-production';
|
||||
|
||||
let userId: string;
|
||||
try {
|
||||
const { payload } = await jose.jwtVerify(token, new TextEncoder().encode(jwtSecret));
|
||||
userId = payload.userId as string;
|
||||
} catch {
|
||||
return c.json({ data: null, error: { code: 'UNAUTHORIZED', message: 'Invalid or expired token' } }, 401);
|
||||
}
|
||||
|
||||
const { name } = await c.req.json();
|
||||
const apiKey = randomBytes(32).toString('hex');
|
||||
const keyHash = createHash('sha256').update(apiKey).digest('hex');
|
||||
|
||||
const prisma = c.get('prisma');
|
||||
|
||||
const keyRecord = await prisma.apiKey.create({
|
||||
data: {
|
||||
userId,
|
||||
keyHash,
|
||||
name: name || 'Default',
|
||||
},
|
||||
});
|
||||
|
||||
return c.json({ data: { apiKey, id: keyRecord.id, name: keyRecord.name }, error: null }, 201);
|
||||
});
|
||||
68
backend/src/routes/days.ts
Normal file
68
backend/src/routes/days.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { Hono } from 'hono';
|
||||
import { HonoEnv } from '../lib/types';
|
||||
|
||||
export const daysRoutes = new Hono<HonoEnv>();
|
||||
|
||||
daysRoutes.get('/', async (c) => {
|
||||
const userId = c.get('userId');
|
||||
const prisma = c.get('prisma');
|
||||
|
||||
const days = await prisma.entry.groupBy({
|
||||
by: ['date'],
|
||||
where: { userId },
|
||||
_count: { id: true },
|
||||
orderBy: { date: 'desc' },
|
||||
});
|
||||
|
||||
const journals = await prisma.journal.findMany({
|
||||
where: { userId },
|
||||
select: { date: true, generatedAt: true, entryCount: true },
|
||||
});
|
||||
|
||||
const journalMap = new Map(journals.map(j => [j.date, j]));
|
||||
|
||||
const result = days.map(day => ({
|
||||
date: day.date,
|
||||
entryCount: day._count.id,
|
||||
hasJournal: journalMap.has(day.date),
|
||||
journalGeneratedAt: journalMap.get(day.date)?.generatedAt,
|
||||
}));
|
||||
|
||||
return c.json({ data: result, error: null });
|
||||
});
|
||||
|
||||
daysRoutes.get('/:date', async (c) => {
|
||||
const userId = c.get('userId');
|
||||
const { date } = c.req.param();
|
||||
const prisma = c.get('prisma');
|
||||
|
||||
const dateRegex = /^\d{4}-\d{2}-\d{2}$/;
|
||||
if (!dateRegex.test(date)) {
|
||||
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' },
|
||||
}),
|
||||
prisma.journal.findUnique({
|
||||
where: { userId_date: { userId, date } },
|
||||
}),
|
||||
]);
|
||||
|
||||
return c.json({ data: { date, entries, journal }, error: null });
|
||||
});
|
||||
|
||||
daysRoutes.delete('/:date', async (c) => {
|
||||
const userId = c.get('userId');
|
||||
const { date } = c.req.param();
|
||||
const prisma = c.get('prisma');
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.entry.deleteMany({ where: { userId, date } }),
|
||||
prisma.journal.deleteMany({ where: { userId, date } }),
|
||||
]);
|
||||
|
||||
return c.json({ data: { deleted: true }, error: null });
|
||||
});
|
||||
166
backend/src/routes/entries.ts
Normal file
166
backend/src/routes/entries.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
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 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);
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
112
backend/src/routes/journal.ts
Normal file
112
backend/src/routes/journal.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { Hono } from 'hono';
|
||||
import { HonoEnv } from '../lib/types';
|
||||
import { AIProvider, createAIProvider } from '../services/ai/provider';
|
||||
|
||||
export const journalRoutes = new Hono<HonoEnv>();
|
||||
|
||||
journalRoutes.post('/generate/:date', async (c) => {
|
||||
const userId = c.get('userId');
|
||||
const { date } = c.req.param();
|
||||
const prisma = c.get('prisma');
|
||||
|
||||
const [entries, settings] = await Promise.all([
|
||||
prisma.entry.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 (!settings?.aiApiKey) {
|
||||
return c.json({ data: null, error: { code: 'NO_AI_CONFIG', message: 'AI not configured. Please set up your API key in settings.' } }, 400);
|
||||
}
|
||||
|
||||
const provider = createAIProvider({
|
||||
provider: settings.aiProvider as AIProvider['provider'],
|
||||
apiKey: settings.aiApiKey,
|
||||
model: settings.aiModel,
|
||||
baseUrl: settings.aiBaseUrl,
|
||||
});
|
||||
|
||||
const entriesText = entries.map(entry => {
|
||||
let text = `[${entry.type.toUpperCase()}] ${entry.createdAt.toISOString()}\n${entry.content}`;
|
||||
if (entry.metadata) {
|
||||
try {
|
||||
const meta = JSON.parse(entry.metadata);
|
||||
if (meta.location) text += `\nLocation: ${meta.location.lat}, ${meta.location.lng}`;
|
||||
if (meta.duration) text += `\nDuration: ${meta.duration}s`;
|
||||
} catch {}
|
||||
}
|
||||
return text;
|
||||
}).join('\n\n');
|
||||
|
||||
const prompt = `The following entries were captured throughout the day (${date}). Write a thoughtful, reflective journal entry 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}
|
||||
|
||||
JOURNAL:`;
|
||||
|
||||
try {
|
||||
const content = await provider.generate(prompt, settings.journalPrompt);
|
||||
|
||||
const journal = await prisma.journal.upsert({
|
||||
where: { userId_date: { userId, date } },
|
||||
create: {
|
||||
userId,
|
||||
date,
|
||||
content,
|
||||
entryCount: entries.length,
|
||||
},
|
||||
update: {
|
||||
content,
|
||||
entryCount: entries.length,
|
||||
generatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
return c.json({ data: journal, error: null });
|
||||
} catch (err) {
|
||||
console.error('AI generation failed:', err);
|
||||
return c.json({ data: null, error: { code: 'AI_ERROR', message: 'Failed to generate journal. Check your AI configuration.' } }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
journalRoutes.get('/:date', 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: 'No journal found for this date' } }, 404);
|
||||
}
|
||||
|
||||
return c.json({ data: journal, error: null });
|
||||
});
|
||||
|
||||
journalRoutes.delete('/:date', async (c) => {
|
||||
const userId = c.get('userId');
|
||||
const { date } = c.req.param();
|
||||
const prisma = c.get('prisma');
|
||||
|
||||
await prisma.journal.deleteMany({
|
||||
where: { userId, date },
|
||||
});
|
||||
|
||||
return c.json({ data: { deleted: true }, error: null });
|
||||
});
|
||||
69
backend/src/routes/settings.ts
Normal file
69
backend/src/routes/settings.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { Hono } from 'hono';
|
||||
import { HonoEnv } from '../lib/types';
|
||||
|
||||
export const settingsRoutes = new Hono<HonoEnv>();
|
||||
|
||||
settingsRoutes.get('/', async (c) => {
|
||||
const userId = c.get('userId');
|
||||
const prisma = c.get('prisma');
|
||||
|
||||
const settings = await prisma.settings.findUnique({
|
||||
where: { userId },
|
||||
});
|
||||
|
||||
if (!settings) {
|
||||
const newSettings = await prisma.settings.create({
|
||||
data: { userId },
|
||||
});
|
||||
return c.json({ data: newSettings, error: null });
|
||||
}
|
||||
|
||||
return c.json({ data: settings, error: null });
|
||||
});
|
||||
|
||||
settingsRoutes.put('/', async (c) => {
|
||||
const userId = c.get('userId');
|
||||
const prisma = c.get('prisma');
|
||||
|
||||
const body = await c.req.json();
|
||||
const { aiProvider, aiApiKey, aiModel, aiBaseUrl, journalPrompt, language } = body;
|
||||
|
||||
const data: Record<string, unknown> = {};
|
||||
if (aiProvider !== undefined) data.aiProvider = aiProvider;
|
||||
if (aiApiKey !== undefined) data.aiApiKey = aiApiKey;
|
||||
if (aiModel !== undefined) data.aiModel = aiModel || 'gpt-4';
|
||||
if (aiBaseUrl !== undefined) data.aiBaseUrl = aiBaseUrl;
|
||||
if (journalPrompt !== undefined) data.journalPrompt = journalPrompt;
|
||||
if (language !== undefined) data.language = language;
|
||||
|
||||
const settings = await prisma.settings.upsert({
|
||||
where: { userId },
|
||||
create: { userId, ...data },
|
||||
update: data,
|
||||
});
|
||||
|
||||
return c.json({ data: settings, error: null });
|
||||
});
|
||||
|
||||
settingsRoutes.post('/validate-key', async (c) => {
|
||||
const body = await c.req.json();
|
||||
const { provider, apiKey, baseUrl } = body;
|
||||
|
||||
if (!provider || !apiKey) {
|
||||
return c.json({ data: null, error: { code: 'VALIDATION_ERROR', message: 'provider and apiKey are required' } }, 400);
|
||||
}
|
||||
|
||||
const { createAIProvider } = await import('../services/ai/provider');
|
||||
|
||||
try {
|
||||
const aiProvider = createAIProvider({
|
||||
provider,
|
||||
apiKey,
|
||||
baseUrl,
|
||||
});
|
||||
const valid = await aiProvider.validate?.();
|
||||
return c.json({ data: { valid: true }, error: null });
|
||||
} catch {
|
||||
return c.json({ data: { valid: false }, error: null });
|
||||
}
|
||||
});
|
||||
64
backend/src/services/ai/anthropic.ts
Normal file
64
backend/src/services/ai/anthropic.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import type { AIProvider, AIProviderConfig } from './provider';
|
||||
|
||||
export class AnthropicProvider implements AIProvider {
|
||||
provider = 'anthropic' as const;
|
||||
private apiKey: string;
|
||||
private model: string;
|
||||
private baseUrl: string;
|
||||
|
||||
constructor(config: AIProviderConfig) {
|
||||
this.apiKey = config.apiKey;
|
||||
this.model = config.model || 'claude-3-sonnet-20240229';
|
||||
this.baseUrl = config.baseUrl || 'https://api.anthropic.com/v1';
|
||||
}
|
||||
|
||||
async generate(prompt: string, systemPrompt?: string): Promise<string> {
|
||||
const response = await fetch(`${this.baseUrl}/messages`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-api-key': this.apiKey,
|
||||
'anthropic-version': '2023-06-01',
|
||||
'anthropic-dangerous-direct-browser-access': 'true',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: this.model,
|
||||
max_tokens: 2000,
|
||||
system: systemPrompt,
|
||||
messages: [
|
||||
{ role: 'user', content: prompt }
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(`Anthropic API error: ${response.status} ${error}`);
|
||||
}
|
||||
|
||||
const data = await response.json() as { content: Array<{ text: string }> };
|
||||
return data.content[0]?.text || '';
|
||||
}
|
||||
|
||||
async validate(): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}/messages`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-api-key': this.apiKey,
|
||||
'anthropic-version': '2023-06-01',
|
||||
'anthropic-dangerous-direct-browser-access': 'true',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: this.model,
|
||||
max_tokens: 1,
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
}),
|
||||
});
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
52
backend/src/services/ai/lmstudio.ts
Normal file
52
backend/src/services/ai/lmstudio.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import type { AIProvider, AIProviderConfig } from './provider';
|
||||
|
||||
export class LMStudioProvider implements AIProvider {
|
||||
provider = 'lmstudio' as const;
|
||||
private baseUrl: string;
|
||||
private model: string;
|
||||
|
||||
constructor(config: AIProviderConfig) {
|
||||
this.baseUrl = config.baseUrl || 'http://localhost:1234/v1';
|
||||
this.model = config.model || 'local-model';
|
||||
}
|
||||
|
||||
async generate(prompt: string, systemPrompt?: string): Promise<string> {
|
||||
const messages: Array<{ role: string; content: string }> = [];
|
||||
|
||||
if (systemPrompt) {
|
||||
messages.push({ role: 'system', content: systemPrompt });
|
||||
}
|
||||
|
||||
messages.push({ role: 'user', content: prompt });
|
||||
|
||||
const response = await fetch(`${this.baseUrl}/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: this.model,
|
||||
messages,
|
||||
temperature: 0.7,
|
||||
max_tokens: 2000,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(`LM Studio API error: ${response.status} ${error}`);
|
||||
}
|
||||
|
||||
const data = await response.json() as { choices: Array<{ message: { content: string } }> };
|
||||
return data.choices[0]?.message?.content || '';
|
||||
}
|
||||
|
||||
async validate(): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}/models`);
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
46
backend/src/services/ai/ollama.ts
Normal file
46
backend/src/services/ai/ollama.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import type { AIProvider, AIProviderConfig } from './provider';
|
||||
|
||||
export class OllamaProvider implements AIProvider {
|
||||
provider = 'ollama' as const;
|
||||
private baseUrl: string;
|
||||
private model: string;
|
||||
|
||||
constructor(config: AIProviderConfig) {
|
||||
this.baseUrl = config.baseUrl || 'http://localhost:11434';
|
||||
this.model = config.model || 'llama3.2';
|
||||
}
|
||||
|
||||
async generate(prompt: string, systemPrompt?: string): Promise<string> {
|
||||
const response = await fetch(`${this.baseUrl}/api/chat`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: this.model,
|
||||
stream: false,
|
||||
messages: [
|
||||
...(systemPrompt ? [{ role: 'system', content: systemPrompt }] : []),
|
||||
{ role: 'user', content: prompt },
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(`Ollama API error: ${response.status} ${error}`);
|
||||
}
|
||||
|
||||
const data = await response.json() as { message: { content: string } };
|
||||
return data.message?.content || '';
|
||||
}
|
||||
|
||||
async validate(): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}/api/tags`);
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
59
backend/src/services/ai/openai.ts
Normal file
59
backend/src/services/ai/openai.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import type { AIProvider, AIProviderConfig } from './provider';
|
||||
|
||||
export class OpenAIProvider implements AIProvider {
|
||||
provider = 'openai' as const;
|
||||
private apiKey: string;
|
||||
private model: string;
|
||||
private baseUrl: string;
|
||||
|
||||
constructor(config: AIProviderConfig) {
|
||||
this.apiKey = config.apiKey;
|
||||
this.model = config.model || 'gpt-4';
|
||||
this.baseUrl = config.baseUrl || 'https://api.openai.com/v1';
|
||||
}
|
||||
|
||||
async generate(prompt: string, systemPrompt?: string): Promise<string> {
|
||||
const messages: Array<{ role: string; content: string }> = [];
|
||||
|
||||
if (systemPrompt) {
|
||||
messages.push({ role: 'system', content: systemPrompt });
|
||||
}
|
||||
|
||||
messages.push({ role: 'user', content: prompt });
|
||||
|
||||
const response = await fetch(`${this.baseUrl}/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${this.apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: this.model,
|
||||
messages,
|
||||
temperature: 0.7,
|
||||
max_tokens: 2000,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(`OpenAI API error: ${response.status} ${error}`);
|
||||
}
|
||||
|
||||
const data = await response.json() as { choices: Array<{ message: { content: string } }> };
|
||||
return data.choices[0]?.message?.content || '';
|
||||
}
|
||||
|
||||
async validate(): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}/models`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${this.apiKey}`,
|
||||
},
|
||||
});
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
32
backend/src/services/ai/provider.ts
Normal file
32
backend/src/services/ai/provider.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
export interface AIProvider {
|
||||
provider: 'openai' | 'anthropic' | 'ollama' | 'lmstudio';
|
||||
generate(prompt: string, systemPrompt?: string): Promise<string>;
|
||||
validate?(): Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface AIProviderConfig {
|
||||
provider: 'openai' | 'anthropic' | 'ollama' | 'lmstudio';
|
||||
apiKey: string;
|
||||
model?: string;
|
||||
baseUrl?: string;
|
||||
}
|
||||
|
||||
import { OpenAIProvider } from './openai';
|
||||
import { AnthropicProvider } from './anthropic';
|
||||
import { OllamaProvider } from './ollama';
|
||||
import { LMStudioProvider } from './lmstudio';
|
||||
|
||||
export function createAIProvider(config: AIProviderConfig): AIProvider {
|
||||
switch (config.provider) {
|
||||
case 'openai':
|
||||
return new OpenAIProvider(config);
|
||||
case 'anthropic':
|
||||
return new AnthropicProvider(config);
|
||||
case 'ollama':
|
||||
return new OllamaProvider(config);
|
||||
case 'lmstudio':
|
||||
return new LMStudioProvider(config);
|
||||
default:
|
||||
throw new Error(`Unknown AI provider: ${config.provider}`);
|
||||
}
|
||||
}
|
||||
21
backend/tsconfig.json
Normal file
21
backend/tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2022"],
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"types": ["@types/bun"]
|
||||
},
|
||||
"include": ["src/**/*", "prisma/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Reference in New Issue
Block a user