@iprep/server 1.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,9 @@
1
+ import { logger } from '../utils/logger.js';
2
+
3
+ export function errorHandler(err, req, res, next) {
4
+ logger.error(`${req.method} ${req.path} →`, err.message);
5
+ const status = err.status || err.statusCode || 500;
6
+ res.status(status).json({
7
+ error: status >= 500 ? 'Internal server error' : err.message,
8
+ });
9
+ }
@@ -0,0 +1,9 @@
1
+ import { logger } from '../utils/logger.js';
2
+
3
+ export function requestLogger(req, res, next) {
4
+ const start = Date.now();
5
+ res.on('finish', () => {
6
+ logger.info(`${req.method} ${req.path} ${res.statusCode} (${Date.now() - start}ms)`);
7
+ });
8
+ next();
9
+ }
@@ -0,0 +1,16 @@
1
+ import { isValidMessage, isValidTutorId, isValidUserId } from '@iprep/shared';
2
+
3
+ export function validateChatRequest(req, res, next) {
4
+ const { userId, tutorId, message } = req.body;
5
+
6
+ if (!isValidUserId(userId)) {
7
+ return res.status(400).json({ error: 'Invalid or missing userId' });
8
+ }
9
+ if (!isValidTutorId(tutorId)) {
10
+ return res.status(400).json({ error: `Invalid tutorId. Must be one of the registered tutors.` });
11
+ }
12
+ if (!isValidMessage(message)) {
13
+ return res.status(400).json({ error: 'Invalid or missing message' });
14
+ }
15
+ next();
16
+ }
@@ -0,0 +1,18 @@
1
+ import { Router } from 'express';
2
+ import { sendMessage } from '../services/chat-service.js';
3
+ import { validateChatRequest } from '../middleware/validation.js';
4
+
5
+ const router = Router();
6
+
7
+ // POST /api/chat
8
+ router.post('/', validateChatRequest, async (req, res, next) => {
9
+ try {
10
+ const { userId, tutorId, message, conversationId } = req.body;
11
+ const result = await sendMessage(userId, tutorId, message, conversationId || null);
12
+ res.json(result);
13
+ } catch (err) {
14
+ next(err);
15
+ }
16
+ });
17
+
18
+ export default router;
@@ -0,0 +1,43 @@
1
+ import { Router } from 'express';
2
+ import { getConversationsByUser, getConversationById, getMessagesByConversation } from '@iprep/db';
3
+ import { isValidTutorId, isValidUserId } from '@iprep/shared';
4
+
5
+ const router = Router();
6
+
7
+ // GET /api/conversations?userId=&tutorId=
8
+ router.get('/', async (req, res, next) => {
9
+ try {
10
+ const { userId, tutorId } = req.query;
11
+ if (!isValidUserId(userId) || !isValidTutorId(tutorId)) {
12
+ return res.status(400).json({ error: 'userId and tutorId are required' });
13
+ }
14
+ const conversations = await getConversationsByUser(userId, tutorId);
15
+ res.json({ conversations });
16
+ } catch (err) {
17
+ next(err);
18
+ }
19
+ });
20
+
21
+ // GET /api/conversations/:id
22
+ router.get('/:id', async (req, res, next) => {
23
+ try {
24
+ const conv = await getConversationById(Number(req.params.id));
25
+ if (!conv) return res.status(404).json({ error: 'Conversation not found' });
26
+ res.json({ conversation: conv });
27
+ } catch (err) {
28
+ next(err);
29
+ }
30
+ });
31
+
32
+ // GET /api/conversations/:id/messages
33
+ router.get('/:id/messages', async (req, res, next) => {
34
+ try {
35
+ const convId = Number(req.params.id);
36
+ const messages = await getMessagesByConversation(convId);
37
+ res.json({ messages });
38
+ } catch (err) {
39
+ next(err);
40
+ }
41
+ });
42
+
43
+ export default router;
@@ -0,0 +1,172 @@
1
+ import { Router } from 'express';
2
+ import fs from 'fs/promises';
3
+ import path from 'path';
4
+ import multer from 'multer';
5
+ import { IprepPaths, isValidTutorId } from '@iprep/shared';
6
+ import {
7
+ prisma,
8
+ createDocument,
9
+ getDocumentsByTutor,
10
+ deleteDocument as dbDeleteDocument,
11
+ } from '@iprep/db';
12
+
13
+ const router = Router();
14
+
15
+ const ALLOWED_EXTS = new Set(['.md', '.txt']);
16
+ const MAX_SIZE = 10 * 1024 * 1024; // 10 MB
17
+
18
+ const upload = multer({
19
+ storage: multer.memoryStorage(),
20
+ limits: { fileSize: MAX_SIZE },
21
+ fileFilter: (_req, file, cb) => {
22
+ const ext = path.extname(file.originalname).toLowerCase();
23
+ if (ALLOWED_EXTS.has(ext)) cb(null, true);
24
+ else cb(new Error(`Unsupported file type: ${ext}`));
25
+ },
26
+ });
27
+
28
+ // File formats that don't need conversion — mark success immediately
29
+ const NO_CONVERT_EXTS = new Set(['.md', '.txt']);
30
+
31
+ function formatExt(filename) {
32
+ return path.extname(filename).replace('.', '').toUpperCase() || 'FILE';
33
+ }
34
+
35
+ function initialStatus(filename) {
36
+ const ext = path.extname(filename).toLowerCase();
37
+ return NO_CONVERT_EXTS.has(ext) ? 'success' : 'pending';
38
+ }
39
+
40
+ // Sync filesystem → DB:
41
+ // 1. Create records for files on disk with no DB entry
42
+ // 2. Fix stale 'pending' status on .md/.txt files that don't need conversion
43
+ async function syncDiskToDB(tutorId) {
44
+ const dir = IprepPaths.documents(tutorId);
45
+
46
+ let entries;
47
+ try {
48
+ entries = await fs.readdir(dir);
49
+ } catch {
50
+ return; // directory doesn't exist yet — nothing to sync
51
+ }
52
+
53
+ const existing = await getDocumentsByTutor(tutorId);
54
+ const trackedByName = new Map(existing.map((d) => [d.originalName, d]));
55
+
56
+ for (const filename of entries) {
57
+ const ext = path.extname(filename).toLowerCase();
58
+ if (!ALLOWED_EXTS.has(ext)) continue;
59
+
60
+ const filePath = path.join(dir, filename);
61
+ let stat;
62
+ try { stat = await fs.stat(filePath); } catch { continue; }
63
+ if (!stat.isFile()) continue;
64
+
65
+ const correct = initialStatus(filename);
66
+ const record = trackedByName.get(filename);
67
+
68
+ if (!record) {
69
+ // File exists on disk but no DB record — auto-import it
70
+ await createDocument({
71
+ tutorId,
72
+ originalName: filename,
73
+ originalFormat: formatExt(filename),
74
+ fileSize: stat.size,
75
+ conversionStatus: correct,
76
+ });
77
+ } else if (record.conversionStatus === 'pending' && correct === 'success') {
78
+ // Existing record has wrong status — fix it
79
+ await prisma.document.update({
80
+ where: { id: record.id },
81
+ data: { conversionStatus: 'success' },
82
+ });
83
+ }
84
+ }
85
+ }
86
+
87
+ // GET /api/documents?tutorId=
88
+ router.get('/', async (req, res, next) => {
89
+ try {
90
+ const { tutorId } = req.query;
91
+ if (!tutorId || !isValidTutorId(tutorId)) {
92
+ return res.status(400).json({ error: 'Valid tutorId is required' });
93
+ }
94
+ // Auto-import any files placed directly on disk without going through upload
95
+ await syncDiskToDB(tutorId);
96
+ const documents = await getDocumentsByTutor(tutorId);
97
+ res.json({ documents });
98
+ } catch (err) {
99
+ next(err);
100
+ }
101
+ });
102
+
103
+ // POST /api/documents (multipart: tutorId + file)
104
+ router.post('/', upload.single('file'), async (req, res, next) => {
105
+ try {
106
+ const { tutorId } = req.body;
107
+
108
+ if (!tutorId || !isValidTutorId(tutorId)) {
109
+ return res.status(400).json({ error: 'Valid tutorId is required' });
110
+ }
111
+ if (!req.file) {
112
+ return res.status(400).json({ error: 'No file uploaded' });
113
+ }
114
+
115
+ // Ensure the documents directory exists
116
+ const destDir = IprepPaths.documents(tutorId);
117
+ await fs.mkdir(destDir, { recursive: true });
118
+
119
+ // Write file to disk
120
+ const destPath = path.join(destDir, req.file.originalname);
121
+ await fs.writeFile(destPath, req.file.buffer);
122
+
123
+ // Create DB record (.md/.txt are already plain text — mark success immediately)
124
+ const document = await createDocument({
125
+ tutorId,
126
+ originalName: req.file.originalname,
127
+ originalFormat: formatExt(req.file.originalname),
128
+ fileSize: req.file.size,
129
+ conversionStatus: initialStatus(req.file.originalname),
130
+ });
131
+
132
+ res.status(201).json({ document, message: 'ok' });
133
+ } catch (err) {
134
+ next(err);
135
+ }
136
+ });
137
+
138
+ // DELETE /api/documents/:docId
139
+ router.delete('/:docId', async (req, res, next) => {
140
+ try {
141
+ const { docId } = req.params;
142
+
143
+ // Fetch record first so we know tutorId + filename
144
+ const doc = await prisma.document.findUnique({ where: { id: docId } });
145
+
146
+ if (!doc) {
147
+ return res.status(404).json({ error: 'Document not found' });
148
+ }
149
+
150
+ // Delete file from disk (ignore if already gone)
151
+ const filePath = path.join(IprepPaths.documents(doc.tutorId), doc.originalName);
152
+ try {
153
+ await fs.unlink(filePath);
154
+ } catch { /* already removed */ }
155
+
156
+ await dbDeleteDocument(docId);
157
+
158
+ res.json({ success: true });
159
+ } catch (err) {
160
+ next(err);
161
+ }
162
+ });
163
+
164
+ // Multer error handler (file size / type rejections)
165
+ router.use((err, _req, res, next) => {
166
+ if (err instanceof multer.MulterError || err.message?.startsWith('Unsupported')) {
167
+ return res.status(400).json({ error: err.message });
168
+ }
169
+ next(err);
170
+ });
171
+
172
+ export default router;
@@ -0,0 +1,9 @@
1
+ import { Router } from 'express';
2
+
3
+ const router = Router();
4
+
5
+ router.get('/', (req, res) => {
6
+ res.json({ status: 'ok', timestamp: new Date().toISOString() });
7
+ });
8
+
9
+ export default router;
@@ -0,0 +1,107 @@
1
+ import { Router } from 'express';
2
+ import fs from 'fs/promises';
3
+ import path from 'path';
4
+ import { listTutors, loadTutorConfig } from '../services/tutor-service.js';
5
+ import { isValidTutorId, IprepPaths } from '@iprep/shared';
6
+
7
+ const TUTORS_DIR = IprepPaths.aitutors;
8
+
9
+ const router = Router();
10
+
11
+ // GET /api/tutors
12
+ router.get('/', async (req, res, next) => {
13
+ try {
14
+ const tutors = await listTutors();
15
+ res.json({ tutors });
16
+ } catch (err) {
17
+ next(err);
18
+ }
19
+ });
20
+
21
+ // GET /api/tutors/:tutorId
22
+ router.get('/:tutorId', async (req, res, next) => {
23
+ try {
24
+ const { tutorId } = req.params;
25
+ if (!isValidTutorId(tutorId)) {
26
+ return res.status(404).json({ error: 'Tutor not found' });
27
+ }
28
+ const config = await loadTutorConfig(tutorId);
29
+ res.json({ tutor: config });
30
+ } catch (err) {
31
+ next(err);
32
+ }
33
+ });
34
+
35
+ // GET /api/tutors/:tutorId/files — list .md config files for a tutor
36
+ router.get('/:tutorId/files', async (req, res, next) => {
37
+ try {
38
+ const { tutorId } = req.params;
39
+ if (!isValidTutorId(tutorId)) {
40
+ return res.status(404).json({ error: 'Tutor not found' });
41
+ }
42
+
43
+ const tutorDir = path.join(TUTORS_DIR, tutorId);
44
+
45
+ let entries;
46
+ try {
47
+ entries = await fs.readdir(tutorDir);
48
+ } catch {
49
+ return res.json({ files: [] });
50
+ }
51
+
52
+ const mdFiles = entries.filter(f => f.endsWith('.md'));
53
+
54
+ const files = await Promise.all(
55
+ mdFiles.map(async (filename) => {
56
+ const filePath = path.join(tutorDir, filename);
57
+ const stat = await fs.stat(filePath);
58
+ return {
59
+ filename,
60
+ lastModified: stat.mtime.toISOString(),
61
+ size: stat.size,
62
+ };
63
+ })
64
+ );
65
+
66
+ res.json({ files });
67
+ } catch (err) {
68
+ next(err);
69
+ }
70
+ });
71
+
72
+ // GET /api/tutors/:tutorId/files/:filename — return raw markdown content
73
+ router.get('/:tutorId/files/:filename', async (req, res, next) => {
74
+ try {
75
+ const { tutorId, filename } = req.params;
76
+
77
+ if (!isValidTutorId(tutorId)) {
78
+ return res.status(404).json({ error: 'Tutor not found' });
79
+ }
80
+
81
+ // Security: only allow .md files, block path traversal
82
+ if (!filename.endsWith('.md') || filename.includes('..') || filename.includes('/') || filename.includes('\\')) {
83
+ return res.status(400).json({ error: 'Invalid filename' });
84
+ }
85
+
86
+ const filePath = path.join(TUTORS_DIR, tutorId, filename);
87
+
88
+ // Verify the resolved path is still inside tutorDir (extra safety)
89
+ const tutorDir = path.join(TUTORS_DIR, tutorId);
90
+ if (!filePath.startsWith(tutorDir + path.sep) && filePath !== tutorDir) {
91
+ return res.status(400).json({ error: 'Invalid filename' });
92
+ }
93
+
94
+ let content;
95
+ try {
96
+ content = await fs.readFile(filePath, 'utf-8');
97
+ } catch {
98
+ return res.status(404).json({ error: 'File not found' });
99
+ }
100
+
101
+ res.json({ filename, content });
102
+ } catch (err) {
103
+ next(err);
104
+ }
105
+ });
106
+
107
+ export default router;
@@ -0,0 +1,69 @@
1
+ import {
2
+ getOrCreateSession,
3
+ updateSessionClaudeId,
4
+ createConversation,
5
+ getRecentMessages,
6
+ saveMessage,
7
+ } from '@iprep/db';
8
+ import { sessionManager, buildSystemPrompt, buildChatPrompt } from '@iprep/adapter-utils';
9
+ import { sanitizeMessage } from '@iprep/shared';
10
+ import { loadTutorConfig } from './tutor-service.js';
11
+ import { logger } from '../utils/logger.js';
12
+
13
+ /**
14
+ * Orchestrates sending a message: loads tutor config, spawns/reuses Claude,
15
+ * saves messages to DB, returns the response.
16
+ */
17
+ export async function sendMessage(userId, tutorId, userMessage, conversationId = null) {
18
+ const message = sanitizeMessage(userMessage);
19
+
20
+ // 1. Load tutor personality from filesystem
21
+ const tutorConfig = await loadTutorConfig(tutorId);
22
+
23
+ // 2. Get or create DB session
24
+ const session = await getOrCreateSession(userId, tutorId);
25
+
26
+ // 3. Get or create conversation
27
+ let convId = conversationId;
28
+ if (!convId) {
29
+ const conv = await createConversation(
30
+ userId,
31
+ session.id,
32
+ tutorId,
33
+ message.slice(0, 60) // Use first 60 chars as title
34
+ );
35
+ convId = conv.id;
36
+ }
37
+
38
+ // 4. Load recent history for context
39
+ const history = await getRecentMessages(convId, 10);
40
+
41
+ // 5. Build prompts
42
+ const systemPrompt = buildSystemPrompt(tutorConfig);
43
+ const fullPrompt = buildChatPrompt(systemPrompt, history, message);
44
+
45
+ // 6. Get or spawn Claude process
46
+ const spawner = await sessionManager.getOrCreate(userId, tutorId, session.claudeSessionId);
47
+
48
+ // 7. Save user message first
49
+ await saveMessage(convId, 'user', message);
50
+
51
+ // 8. Send to Claude and get response
52
+ logger.info(`Sending message to Claude for user=${userId} tutor=${tutorId}`);
53
+ const response = await spawner.sendPrompt(fullPrompt);
54
+
55
+ // 9. Save the assistant response
56
+ await saveMessage(convId, 'assistant', response);
57
+
58
+ // 10. Update session with Claude's session ID for continuity
59
+ if (spawner.sessionId && spawner.sessionId !== session.claudeSessionId) {
60
+ await updateSessionClaudeId(session.id, spawner.sessionId);
61
+ }
62
+
63
+ return {
64
+ response,
65
+ tutorId,
66
+ conversationId: convId,
67
+ sessionId: session.id,
68
+ };
69
+ }
@@ -0,0 +1,53 @@
1
+ import fs from 'fs/promises';
2
+ import path from 'path';
3
+ import { DEFAULT_TUTORS, IprepPaths } from '@iprep/shared';
4
+ import { upsertTutor, getAllTutors } from '@iprep/db';
5
+ import { logger } from '../utils/logger.js';
6
+
7
+ const TUTORS_DIR = IprepPaths.aitutors;
8
+
9
+ async function readFile(filePath) {
10
+ try {
11
+ return await fs.readFile(filePath, 'utf-8');
12
+ } catch {
13
+ return null;
14
+ }
15
+ }
16
+
17
+ /**
18
+ * Load a tutor's config from the filesystem (markdown files).
19
+ */
20
+ export async function loadTutorConfig(tutorId) {
21
+ const dir = path.join(TUTORS_DIR, tutorId);
22
+ const [systemPrompt, skills, settingsRaw] = await Promise.all([
23
+ readFile(path.join(dir, 'system-prompt.md')),
24
+ readFile(path.join(dir, 'skills.md')),
25
+ readFile(path.join(dir, 'settings.md')),
26
+ ]);
27
+
28
+ const meta = DEFAULT_TUTORS.find((t) => t.id === tutorId) || { id: tutorId, name: tutorId };
29
+
30
+ return {
31
+ ...meta,
32
+ systemPrompt: systemPrompt || `You are ${meta.name}, a helpful AI tutor.`,
33
+ skills: skills || '',
34
+ settings: settingsRaw || '',
35
+ };
36
+ }
37
+
38
+ /**
39
+ * Seed the database with the default tutors if they don't exist yet.
40
+ */
41
+ export async function seedTutors() {
42
+ for (const tutor of DEFAULT_TUTORS) {
43
+ await upsertTutor(tutor);
44
+ logger.info(`Tutor seeded: ${tutor.name}`);
45
+ }
46
+ }
47
+
48
+ /**
49
+ * List all tutors from the DB, enriched with filesystem config.
50
+ */
51
+ export async function listTutors() {
52
+ return getAllTutors();
53
+ }
@@ -0,0 +1,10 @@
1
+ import dotenv from 'dotenv';
2
+ import os from 'os';
3
+ import path from 'path';
4
+
5
+ // Load .env from ~/.iprep/.env (user's home directory)
6
+ dotenv.config({ path: path.join(os.homedir(), '.iprep', '.env') });
7
+
8
+ export const PORT = parseInt(process.env.PORT || '3000', 10);
9
+ export const NODE_ENV = process.env.NODE_ENV || 'development';
10
+ export const LOG_LEVEL = process.env.LOG_LEVEL || 'info';
@@ -0,0 +1,3 @@
1
+ import { logger as baseLogger } from '@iprep/shared';
2
+ export const logger = baseLogger;
3
+ export default logger;