-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathserver-sqlite.js
More file actions
3606 lines (3211 loc) · 129 KB
/
server-sqlite.js
File metadata and controls
3606 lines (3211 loc) · 129 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
const express = require('express');
const cors = require('cors');
const helmet = require('helmet');
const rateLimit = require('express-rate-limit');
const sqlite3 = require('sqlite3').verbose();
const path = require('path');
const fs = require('fs');
const { logger, httpLogger } = require('./logger');
require('dotenv').config({ path: '.env.production' });
require('dotenv').config({ path: 'prompt-search/.env' }); // ANTHROPIC_API_KEY, NVIDIA_API_KEY
const axios = require('axios');
const multer = require('multer');
const pdfParse = require('pdf-parse');
const Anthropic = require('@anthropic-ai/sdk');
// Security: Validate required environment variables
const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET;
const { executeNotebook } = require('./prompt-search/scripts/execute_notebook');
if (!WEBHOOK_SECRET) {
logger.error('WEBHOOK_SECRET not set! Please set it in .env.production before starting.');
logger.error('Generate one with: node -e "console.log(require(\'crypto\').randomBytes(32).toString(\'hex\'))"');
process.exit(1);
}
const app = express();
const PORT = process.env.API_PORT || 3008;
const DATA_DIR = process.env.DATA_DIR || './data';
const DB_PATH = path.join(DATA_DIR, 'courses.db');
// Configure `trust proxy` from env to avoid permissive defaults.
// By default we do NOT trust proxies (safer for rate-limiting).
// Set `TRUST_PROXY` env to 'true', 'false', a number, or an address list when behind a reverse proxy.
let trustProxyValue = false;
if (process.env.TRUST_PROXY !== undefined) {
const v = process.env.TRUST_PROXY;
if (v === 'true') trustProxyValue = true;
else if (v === 'false') trustProxyValue = false;
else if (!Number.isNaN(Number(v))) trustProxyValue = Number(v);
else trustProxyValue = v; // allow string like 'loopback' or a comma-separated list
}
app.set('trust proxy', trustProxyValue);
// Middleware
// Security: Lock down CORS to specific origins
const allowedOrigins = process.env.NODE_ENV === 'production'
? ['https://virtual-counselor.org', 'https://n8n.virtual-counselor.org']
: ['http://localhost:3007', 'http://localhost:3009'];
app.use(cors({
origin: (origin, callback) => {
// Allow requests with no origin (like mobile apps or Postman)
if (!origin) return callback(null, true);
if (allowedOrigins.includes(origin)) {
callback(null, true);
} else {
callback(new Error(`Origin ${origin} not allowed by CORS`));
}
},
credentials: true,
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization']
}));
// Security: Add helmet for secure HTTP headers
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"],
scriptSrc: ["'self'"],
imgSrc: ["'self'", "data:", "https:"],
},
},
crossOriginEmbedderPolicy: false, // Allow embedding for API responses
}));
// Security: Rate limiting for API endpoints
const apiLimiter = rateLimit({
windowMs: parseInt(process.env.API_RATE_LIMIT_WINDOW_MS, 100) || 5 * 60 * 1000, // 5 minutes
max: parseInt(process.env.API_RATE_LIMIT_MAX, 100) || 100,
message: { error: 'Too many requests, please try again later' },
standardHeaders: true,
legacyHeaders: false,
});
// Security: Stricter rate limiting for webhooks (tunable via env vars)
const webhookLimiter = rateLimit({
windowMs: parseInt(process.env.WEBHOOK_RATE_LIMIT_WINDOW_MS, 10) || 60 * 1000, // 1 minute
// Default bumped for internal scrapes; reduce in production if exposed publicly
max: parseInt(process.env.WEBHOOK_RATE_LIMIT_MAX, 10) || 200,
message: { error: 'Too many webhook calls, please try again later' },
standardHeaders: true,
legacyHeaders: false,
});
// Apply rate limiting
app.use('/api/', apiLimiter);
// Webhook rate limiting can be disabled by setting WEBHOOK_RATE_LIMIT_MAX=0
if (parseInt(process.env.WEBHOOK_RATE_LIMIT_MAX, 10) === 0) {
console.log('[WARN] Webhook rate limiting DISABLED via WEBHOOK_RATE_LIMIT_MAX=0');
} else {
app.use('/webhook/', webhookLimiter);
}
// Payload size: increased to allow large batch posts from n8n (tunable via env)
app.use(express.json({ limit: process.env.EXPRESS_JSON_LIMIT || '50mb' }));
app.use(express.urlencoded({ extended: true, limit: process.env.EXPRESS_JSON_LIMIT || '50mb' }));
// Structured HTTP logging middleware
app.use(httpLogger);
// Security: Webhook authentication middleware
const webhookAuth = (req, res, next) => {
const authHeader = req.headers['authorization'];
if (!authHeader) {
logger.warn('Webhook request rejected: Missing Authorization header', { meta: { ip: req.ip, path: req.path } });
return res.status(401).json({
success: false,
error: 'Unauthorized: Missing Authorization header'
});
}
if (authHeader !== `Bearer ${WEBHOOK_SECRET}`) {
logger.warn('Webhook request rejected: Invalid credentials', { meta: { ip: req.ip, path: req.path } });
return res.status(401).json({
success: false,
error: 'Unauthorized: Invalid credentials'
});
}
// Authentication successful
next();
};
// Ensure data directory exists
if (!fs.existsSync(DATA_DIR)) {
fs.mkdirSync(DATA_DIR, { recursive: true });
}
// ============================================
// DATABASE SETUP
// ============================================
const db = new sqlite3.Database(DB_PATH, (err) => {
if (err) {
logger.error('Error opening database', { meta: { error: err.message, path: DB_PATH } });
process.exit(1);
}
logger.info(`Connected to SQLite database: ${DB_PATH}`);
});
// Enable WAL mode for better concurrent access
db.run('PRAGMA journal_mode = WAL');
db.run('PRAGMA busy_timeout = 5000'); // Wait up to 5 seconds for locks
// Create tables
db.serialize(() => {
// Main courses table
db.run(`
CREATE TABLE IF NOT EXISTS courses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
uniqueId TEXT UNIQUE NOT NULL,
campus TEXT NOT NULL,
term TEXT NOT NULL,
year INTEGER NOT NULL,
prefix TEXT NOT NULL,
subject TEXT,
courseNumber TEXT NOT NULL,
sectionNumber TEXT NOT NULL,
isLab BOOLEAN NOT NULL,
title TEXT,
sectionTitle TEXT,
credits TEXT,
instructor TEXT,
sln INTEGER,
-- Course details
courseDescription TEXT,
coursePrerequisite TEXT,
sectionComment TEXT,
sectionUrl TEXT,
dayTime TEXT,
location TEXT,
site TEXT,
startDate TEXT,
endDate TEXT,
-- Enrollment data (updated hourly)
seatsAvailable INTEGER,
maxEnrollment INTEGER,
currentEnrollment INTEGER,
waitlistAvailable INTEGER,
waitlistCapacity INTEGER,
waitlistCount INTEGER,
status TEXT,
-- Important dates
dateLastAuditToCredit TEXT,
dateLastCreditToAudit TEXT,
dateLastFinalGradeSubmit TEXT,
dateLastInstruction TEXT,
dateLastLtrGradeToPf TEXT,
dateLastPftoLtrGrade TEXT,
dateLastRegWithoutFee TEXT,
dateLastStdAdd TEXT,
dateLastStdDrop TEXT,
dateLastWdrwl TEXT,
dateRegBegin TEXT,
dateRegEnd TEXT,
-- Course attributes
slnrestrict BOOLEAN,
ger TEXT,
diversity BOOLEAN,
writing BOOLEAN,
courseFee REAL,
isMultipleFees BOOLEAN,
titleAllowed BOOLEAN,
showInstructors BOOLEAN,
ucore TEXT,
coop TEXT,
schedulePrint TEXT,
instructionMode TEXT,
session TEXT,
consent TEXT,
minUnits TEXT,
maxUnits TEXT,
gradCaps TEXT,
footnotes TEXT,
-- Complex data as JSON
instructors TEXT,
meetings TEXT,
-- Metadata
scrapedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
createdAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
-- Additional fields (stored as JSON for flexibility)
additionalData TEXT
)
`);
// Enrollment history (time-series data)
db.run(`
CREATE TABLE IF NOT EXISTS enrollment_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
courseId INTEGER NOT NULL,
uniqueId TEXT NOT NULL,
seatsAvailable INTEGER,
currentEnrollment INTEGER,
waitlistCount INTEGER,
scrapedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (courseId) REFERENCES courses(id)
)
`);
// ============================================
// HISTORICAL CATALOG DATA TABLES
// These store degree requirements by catalog year
// so students can see their exact requirements from when they started
// ============================================
// Departments table (historical - by catalog year)
db.run(`
CREATE TABLE IF NOT EXISTS departments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
uniqueId TEXT UNIQUE NOT NULL,
catalogYear TEXT NOT NULL,
academicUnitId INTEGER,
name TEXT NOT NULL,
title TEXT,
fullName TEXT,
url TEXT,
location TEXT,
phone TEXT,
facultyList TEXT,
description TEXT,
sourceType TEXT DEFAULT 'api',
createdAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
`);
// Degree Programs table (historical - by catalog year)
db.run(`
CREATE TABLE IF NOT EXISTS degree_programs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
uniqueId TEXT UNIQUE NOT NULL,
catalogYear TEXT NOT NULL,
departmentId INTEGER,
externalId INTEGER,
title TEXT NOT NULL,
hours INTEGER,
narrative TEXT,
bottomText TEXT,
isHonors BOOLEAN DEFAULT 0,
isFYDA BOOLEAN DEFAULT 0,
yearFormat TEXT,
yearEnd TEXT,
termEnd TEXT,
sequenceItems TEXT,
sourceType TEXT DEFAULT 'api',
createdAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (departmentId) REFERENCES departments(id)
)
`);
// Minors table (historical - by catalog year)
db.run(`
CREATE TABLE IF NOT EXISTS minors (
id INTEGER PRIMARY KEY AUTOINCREMENT,
uniqueId TEXT UNIQUE NOT NULL,
catalogYear TEXT NOT NULL,
departmentId INTEGER,
externalId INTEGER,
title TEXT NOT NULL,
narrative TEXT,
yearEnd TEXT,
termEnd TEXT,
sourceType TEXT DEFAULT 'api',
createdAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (departmentId) REFERENCES departments(id)
)
`);
// Certificates table (historical - by catalog year)
db.run(`
CREATE TABLE IF NOT EXISTS certificates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
uniqueId TEXT UNIQUE NOT NULL,
catalogYear TEXT NOT NULL,
departmentId INTEGER,
externalId INTEGER,
title TEXT NOT NULL,
description TEXT,
sourceType TEXT DEFAULT 'api',
createdAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (departmentId) REFERENCES departments(id)
)
`);
// Legacy degrees table (keeping for backwards compatibility)
db.run(`
CREATE TABLE IF NOT EXISTS degrees (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
degreeType TEXT,
type TEXT NOT NULL,
year INTEGER NOT NULL,
catalogType TEXT,
college TEXT,
totalCredits INTEGER,
sourceUrl TEXT,
createdAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
`);
// Archived Catalog PDFs table (metadata + optional blob)
db.run(`
CREATE TABLE IF NOT EXISTS catalog_pdfs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
filename TEXT UNIQUE NOT NULL,
catalogYear TEXT NOT NULL,
description TEXT,
fileSize INTEGER,
filePath TEXT,
pdfData BLOB,
mimeType TEXT DEFAULT 'application/pdf',
parsedAt TIMESTAMP,
createdAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
`);
// Create indexes for fast queries - HISTORICAL DATA
db.run('CREATE INDEX IF NOT EXISTS idx_departments_year ON departments(catalogYear)');
db.run('CREATE INDEX IF NOT EXISTS idx_departments_name ON departments(name)');
db.run('CREATE INDEX IF NOT EXISTS idx_degree_programs_year ON degree_programs(catalogYear)');
db.run('CREATE INDEX IF NOT EXISTS idx_degree_programs_dept ON degree_programs(departmentId)');
db.run('CREATE INDEX IF NOT EXISTS idx_degree_programs_title ON degree_programs(title)');
db.run('CREATE INDEX IF NOT EXISTS idx_minors_year ON minors(catalogYear)');
db.run('CREATE INDEX IF NOT EXISTS idx_minors_dept ON minors(departmentId)');
db.run('CREATE INDEX IF NOT EXISTS idx_certificates_year ON certificates(catalogYear)');
db.run('CREATE INDEX IF NOT EXISTS idx_certificates_dept ON certificates(departmentId)');
db.run('CREATE INDEX IF NOT EXISTS idx_catalog_pdfs_year ON catalog_pdfs(catalogYear)');
db.run('CREATE INDEX IF NOT EXISTS idx_courses_uniqueId ON courses(uniqueId)');
db.run('CREATE INDEX IF NOT EXISTS idx_courses_semester ON courses(campus, term, year)');
db.run('CREATE INDEX IF NOT EXISTS idx_courses_prefix ON courses(prefix, courseNumber)');
db.run('CREATE INDEX IF NOT EXISTS idx_courses_seats ON courses(seatsAvailable)');
db.run('CREATE INDEX IF NOT EXISTS idx_enrollment_history_course ON enrollment_history(courseId)');
db.run('CREATE INDEX IF NOT EXISTS idx_enrollment_history_scraped ON enrollment_history(scrapedAt)');
// Add external_id column for WSU API degree IDs (migration)
db.run('ALTER TABLE catalog_degrees ADD COLUMN external_id TEXT', (err) => {
if (err && !err.message.includes('duplicate column')) {
console.error('Warning: Could not add external_id column:', err.message);
} else if (!err) {
console.log('[OK] Added external_id column to catalog_degrees');
}
});
// Create table for degree course requirements (sequenceItems from WSU API)
db.run(`
CREATE TABLE IF NOT EXISTS degree_requirements (
id INTEGER PRIMARY KEY AUTOINCREMENT,
degree_id INTEGER NOT NULL,
catalog_year TEXT NOT NULL,
year INTEGER NOT NULL,
term INTEGER NOT NULL,
label TEXT,
hours TEXT,
sort_order INTEGER,
footnotes TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (degree_id) REFERENCES catalog_degrees(id) ON DELETE CASCADE
)
`, (err) => {
if (err) {
console.error('Warning: Could not create degree_requirements table:', err.message);
}
});
db.run('CREATE INDEX IF NOT EXISTS idx_degree_requirements_degree ON degree_requirements(degree_id)', (err) => {
if (err && !err.message.includes('already exists')) {
console.error('Warning: Could not create index on degree_requirements:', err.message);
}
});
// Catalog courses table (courses referenced in degree/program catalogs, separate from live `courses` table)
db.run(`
CREATE TABLE IF NOT EXISTS catalog_courses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
unique_id TEXT NOT NULL,
catalog_year TEXT NOT NULL,
code TEXT,
prefix TEXT,
number TEXT,
title TEXT,
description TEXT,
credits REAL,
credits_phrase TEXT,
ucore TEXT,
prerequisite_raw TEXT,
prerequisite_codes TEXT,
offered_raw TEXT,
offered_terms TEXT,
attributes TEXT,
footnotes TEXT,
alternatives TEXT,
is_non_credit BOOLEAN DEFAULT 0,
source_type TEXT DEFAULT 'api',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
`, (err) => {
if (err) console.error('Warning: Could not create catalog_courses table:', err.message);
});
db.run('CREATE UNIQUE INDEX IF NOT EXISTS idx_catalog_courses_unique ON catalog_courses(unique_id, catalog_year)');
db.run('CREATE INDEX IF NOT EXISTS idx_catalog_courses_code ON catalog_courses(code)');
// Catalog minors table (historical data with narratives)
db.run(`
CREATE TABLE IF NOT EXISTS catalog_minors (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
catalog_year TEXT NOT NULL,
url TEXT,
source_type TEXT DEFAULT 'api',
narrative TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(name, catalog_year)
)
`, (err) => {
if (err) console.error('Warning: Could not create catalog_minors table:', err.message);
});
db.run('CREATE INDEX IF NOT EXISTS idx_catalog_minors_year ON catalog_minors(catalog_year)');
db.run('CREATE INDEX IF NOT EXISTS idx_catalog_minors_name ON catalog_minors(name)');
// Catalog certificates table (historical data with descriptions)
db.run(`
CREATE TABLE IF NOT EXISTS catalog_certificates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
catalog_year TEXT NOT NULL,
url TEXT,
source_type TEXT DEFAULT 'api',
description TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(name, catalog_year)
)
`, (err) => {
if (err) console.error('Warning: Could not create catalog_certificates table:', err.message);
});
db.run('CREATE INDEX IF NOT EXISTS idx_catalog_certificates_year ON catalog_certificates(catalog_year)');
db.run('CREATE INDEX IF NOT EXISTS idx_catalog_certificates_name ON catalog_certificates(name)');
// Add courses columns if they don't exist (for existing databases)
db.run('ALTER TABLE catalog_minors ADD COLUMN courses TEXT', () => {});
db.run('ALTER TABLE catalog_minors ADD COLUMN required_courses TEXT', () => {});
db.run('ALTER TABLE catalog_minors ADD COLUMN elective_courses TEXT', () => {});
db.run('ALTER TABLE catalog_certificates ADD COLUMN courses TEXT', () => {});
db.run('ALTER TABLE catalog_certificates ADD COLUMN required_courses TEXT', () => {});
db.run('ALTER TABLE catalog_certificates ADD COLUMN elective_courses TEXT', () => {});
console.log('[OK] Database tables created/verified');
});
// ============================================
// HELPER FUNCTIONS
// ============================================
// Promisify database operations
function dbRun(sql, params = []) {
return new Promise((resolve, reject) => {
db.run(sql, params, function(err) {
if (err) reject(err);
else resolve(this);
});
});
}
function dbGet(sql, params = []) {
return new Promise((resolve, reject) => {
db.get(sql, params, (err, row) => {
if (err) reject(err);
else resolve(row);
});
});
}
function dbAll(sql, params = []) {
return new Promise((resolve, reject) => {
db.all(sql, params, (err, rows) => {
if (err) reject(err);
else resolve(rows);
});
});
}
// Transaction lock to prevent concurrent transactions (SQLite limitation)
let transactionLock = Promise.resolve();
let isInTransaction = false;
// Execute a function within a database transaction
// Automatically commits on success or rolls back on error
// Uses a lock to serialize concurrent transaction requests
async function withTransaction(callback) {
// Wait for any pending transaction to complete
const previousLock = transactionLock;
let releaseLock;
transactionLock = new Promise(resolve => { releaseLock = resolve; });
await previousLock;
if (isInTransaction) {
// Already in a transaction, just run the callback without wrapping
releaseLock();
return callback();
}
isInTransaction = true;
try {
await dbRun('BEGIN IMMEDIATE');
const result = await callback();
await dbRun('COMMIT');
return result;
} catch (error) {
try {
await dbRun('ROLLBACK');
} catch (rollbackErr) {
logger.error('Rollback failed', { meta: { error: rollbackErr.message } });
}
throw error;
} finally {
isInTransaction = false;
releaseLock();
}
}
// Standardized error response helpers
function sendError(res, statusCode, message, details = null) {
const response = {
status: 'error',
message: message
};
if (details) {
response.details = details;
}
return res.status(statusCode).json(response);
}
function sendBadRequest(res, message, details = null) {
return sendError(res, 400, message, details);
}
function sendUnauthorized(res, message = 'Unauthorized') {
return sendError(res, 401, message);
}
function sendNotFound(res, message = 'Resource not found') {
return sendError(res, 404, message);
}
function sendServerError(res, error) {
console.error('Server error:', error);
return sendError(res, 500, 'Internal server error', error.message);
}
// Transaction wrapper - alias for withTransaction (uses same lock)
async function runInTransaction(callback) {
return withTransaction(callback);
}
// ============================================
// AUTO-IMPORT HISTORICAL CATALOG DATA
// ============================================
// Import historical catalog minors/certificates from parsed PDF JSON files
// Only runs once if the data isn't already populated
async function importHistoricalCatalogData() {
const catalogDir = path.join(__dirname, 'pdf-archieved-catalog');
// Check if catalog directory exists
if (!fs.existsSync(catalogDir)) {
console.log('[INFO] No pdf-archieved-catalog directory found, skipping historical import');
return;
}
try {
// Check if historical data already exists with narratives/descriptions AND courses
const minorCheck = await dbGet(
'SELECT COUNT(*) as count FROM catalog_minors WHERE narrative IS NOT NULL AND narrative != ""'
);
const certCheck = await dbGet(
'SELECT COUNT(*) as count FROM catalog_certificates WHERE description IS NOT NULL AND description != ""'
);
// Also check if courses are populated
const minorCoursesCheck = await dbGet(
'SELECT COUNT(*) as count FROM catalog_minors WHERE courses IS NOT NULL AND courses != "[]" AND courses != ""'
);
// If we already have substantial data WITH courses, skip import
if ((minorCheck?.count || 0) > 100 && (certCheck?.count || 0) > 50 && (minorCoursesCheck?.count || 0) > 50) {
console.log(`[INFO] Historical catalog data already populated (${minorCheck.count} minors, ${certCheck.count} certificates with narratives, ${minorCoursesCheck.count} with courses)`);
return;
}
// If data exists but courses are missing, we'll re-import to add courses
if ((minorCheck?.count || 0) > 100 && (minorCoursesCheck?.count || 0) < 50) {
console.log('[INFO] Re-importing historical catalog data to add extracted courses...');
}
console.log('[INFO] Importing historical catalog data from PDF archives...');
// Find all parsed JSON files
const jsonFiles = fs.readdirSync(catalogDir)
.filter(f => f.endsWith('-parsed.json'))
.sort();
if (jsonFiles.length === 0) {
console.log(' No -parsed.json files found in pdf-archieved-catalog');
return;
}
let totalMinors = 0;
let totalCerts = 0;
for (const jsonFile of jsonFiles) {
const year = jsonFile.match(/(\d{4})/)?.[1];
if (!year) continue;
const filePath = path.join(catalogDir, jsonFile);
const data = JSON.parse(fs.readFileSync(filePath, 'utf8'));
// Import minors (with extracted courses)
for (const minor of (data.minors || [])) {
try {
await dbRun(`
INSERT OR REPLACE INTO catalog_minors
(name, catalog_year, url, source_type, narrative, courses, required_courses, elective_courses)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`, [
minor.name,
year,
null,
'pdf',
minor.narrative || null,
JSON.stringify(minor.courses || []),
JSON.stringify(minor.requiredCourses || []),
JSON.stringify(minor.electiveCourses || [])
]);
totalMinors++;
} catch (err) {
// Ignore duplicate errors
}
}
// Import certificates (with extracted courses)
for (const cert of (data.certificates || [])) {
try {
await dbRun(`
INSERT OR REPLACE INTO catalog_certificates
(name, catalog_year, url, source_type, description, courses, required_courses, elective_courses)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`, [
cert.name,
year,
null,
'pdf',
cert.description || null,
JSON.stringify(cert.courses || []),
JSON.stringify(cert.requiredCourses || []),
JSON.stringify(cert.electiveCourses || [])
]);
totalCerts++;
} catch (err) {
// Ignore duplicate errors
}
}
}
console.log(` [OK] Imported ${totalMinors} minors and ${totalCerts} certificates from ${jsonFiles.length} catalog years`);
} catch (err) {
console.error(' [WARN] Error importing historical catalog data:', err.message);
}
}
// Run import after a short delay to ensure tables are created
setTimeout(() => {
importHistoricalCatalogData().catch(err => {
console.error('Failed to import historical catalog data:', err);
});
}, 2000);
// ============================================
// API ENDPOINTS
// ============================================
// Health check
app.get('/health', async (req, res) => {
try {
const stats = await dbGet('SELECT COUNT(*) as count FROM courses');
res.json({
status: 'healthy',
timestamp: new Date().toISOString(),
database: 'connected',
totalCourses: stats.count
});
} catch (error) {
res.status(500).json({
status: 'unhealthy',
error: error.message
});
}
});
// Get statistics
app.get('/api/stats', async (req, res) => {
try {
const [total, byCampus, byTerm, topPrefixes] = await Promise.all([
dbGet('SELECT COUNT(*) as count FROM courses'),
dbAll(`
SELECT campus, COUNT(*) as count
FROM courses
GROUP BY campus
ORDER BY count DESC
`),
dbAll(`
SELECT term, COUNT(*) as count
FROM courses
GROUP BY term
ORDER BY count DESC
`),
dbAll(`
SELECT prefix, COUNT(*) as count
FROM courses
GROUP BY prefix
ORDER BY count DESC
LIMIT 20
`)
]);
res.json({
totalCourses: total.count,
byCampus: byCampus.reduce((acc, r) => ({ ...acc, [r.campus]: r.count }), {}),
byTerm: byTerm.reduce((acc, r) => ({ ...acc, [r.term]: r.count }), {}),
topPrefixes: topPrefixes.map(r => ({ prefix: r.prefix, count: r.count }))
});
} catch (error) {
console.error('Error generating stats:', error);
res.status(500).json({ error: error.message });
}
});
// Get available terms (semesters)
app.get('/api/terms', async (req, res) => {
try {
const terms = await dbAll(`
SELECT DISTINCT term, year, campus, COUNT(*) as courseCount
FROM courses
GROUP BY term, year, campus
ORDER BY year DESC,
CASE term
WHEN 'Fall' THEN 1
WHEN 'Spring' THEN 2
WHEN 'Summer' THEN 3
ELSE 4
END
`);
res.json(terms);
} catch (error) {
console.error('Error fetching terms:', error);
res.status(500).json({ error: error.message });
}
});
// Get available prefixes (subjects)
app.get('/api/prefixes', async (req, res) => {
try {
const { term, year, campus } = req.query;
let whereClauses = [];
let params = [];
if (term) {
whereClauses.push('LOWER(term) = LOWER(?)');
params.push(term);
}
if (year) {
whereClauses.push('year = ?');
params.push(parseInt(year, 10));
}
if (campus) {
whereClauses.push('LOWER(campus) = LOWER(?)');
params.push(campus);
}
const whereClause = whereClauses.length > 0 ? 'WHERE ' + whereClauses.join(' AND ') : '';
const prefixes = await dbAll(`
SELECT DISTINCT prefix, subject, COUNT(*) as courseCount
FROM courses
${whereClause}
GROUP BY prefix
ORDER BY prefix
`, params);
res.json(prefixes);
} catch (error) {
console.error('Error fetching prefixes:', error);
res.status(500).json({ error: error.message });
}
});
// Get all degrees (for degree planner)
app.get('/api/degrees', async (req, res) => {
try {
const { year, search } = req.query;
let whereClauses = [];
let params = [];
// Default to most recent year if not specified
const catalogYear = year || new Date().getFullYear().toString();
whereClauses.push('catalog_year = ?');
params.push(catalogYear);
if (search) {
whereClauses.push('name LIKE ?');
params.push(`%${search}%`);
}
const whereClause = 'WHERE ' + whereClauses.join(' AND ');
// Union catalog_degrees with catalog_minors and catalog_certificates so all programs appear
const allDegrees = await dbAll(`
SELECT id, catalog_year, name, credits, degree_type, college, url, source_type, external_id, narrative
FROM catalog_degrees
${whereClause}
UNION
SELECT id, catalog_year, name, NULL as credits, 'minor' as degree_type, NULL as college, url, source_type, NULL as external_id, narrative
FROM catalog_minors
${whereClause}
UNION
SELECT id, catalog_year, name, NULL as credits, 'certificate' as degree_type, NULL as college, url, source_type, NULL as external_id, description as narrative
FROM catalog_certificates
${whereClause}
ORDER BY name, source_type DESC
`, [...params, ...params, ...params]);
// Deduplicate by name+type (case-insensitive), preferring 'api' over 'catalog_json'
const deduped = new Map();
for (const degree of allDegrees) {
const key = `${degree.name.toLowerCase()}||${degree.degree_type}`;
const existing = deduped.get(key);
// Keep this entry if: no existing entry, or this is from API and existing is from catalog_json
if (!existing || (degree.source_type === 'api' && existing.source_type === 'catalog_json')) {
deduped.set(key, degree);
}
}
const degrees = Array.from(deduped.values()).map(d => ({
id: d.id,
catalog_year: d.catalog_year,
name: d.name,
credits: d.credits,
degree_type: d.degree_type,
college: d.college,
url: d.url,
external_id: d.external_id,
narrative: d.narrative
}));
// Get available years for dropdown
const years = await dbAll(`
SELECT DISTINCT catalog_year
FROM catalog_degrees
ORDER BY catalog_year DESC
`);
res.json({
degrees,
years: years.map(y => y.catalog_year),
total: degrees.length
});
} catch (error) {
console.error('Error fetching degrees:', error);
res.status(500).json({ error: error.message });
}
});
// Get degree requirements from database or WSU Catalog API
// First tries database (for saved requirements), then falls back to WSU API
app.get('/api/degree-requirements', async (req, res) => {
try {
const rawType = req.query.type;
// Normalize "major" -> "degree" for backwards compat
const typeRaw = typeof rawType === 'string' ? rawType : 'degree';
const type = typeRaw === 'major' ? 'degree' : typeRaw;
const { name, acadUnitId: providedAcadUnitId } = req.query;
if (!['degree', 'minor', 'certificate'].includes(type)) {
return res.status(400).json({ error: 'Invalid type parameter' });
}
if (!name || typeof name !== 'string') {
return res.status(400).json({ error: 'Degree name is required' });
}
// STEP 1: Try to fetch from database first based on type
let dbDegree = null;
if (type === 'minor') {
dbDegree = await dbGet(
'SELECT id, name, url, catalog_year, narrative, courses, required_courses, elective_courses FROM catalog_minors WHERE name = ? ORDER BY catalog_year DESC LIMIT 1',
[name]
);
} else if (type === 'certificate') {
dbDegree = await dbGet(
'SELECT id, name, url, catalog_year, description, courses, required_courses, elective_courses FROM catalog_certificates WHERE name = ? ORDER BY catalog_year DESC LIMIT 1',
[name]
);
} else {
// Default: degree
dbDegree = await dbGet(
'SELECT id, name, credits, college, url, catalog_year, narrative FROM catalog_degrees WHERE name = ? ORDER BY catalog_year DESC LIMIT 1',
[name]
);
}
if (dbDegree) {
// Check if we have course requirements saved
const requirements = await dbAll(
'SELECT year, term, label, hours, sort_order, footnotes FROM degree_requirements WHERE degree_id = ? ORDER BY year, term, sort_order',
[dbDegree.id]
);
if (requirements.length > 0) {
// We have saved requirements - use them!
console.log(`[OK] Loaded ${requirements.length} course requirements from database for "${name}"`);
// Group by year and term (deduplicate identical rows from DB)
const coursesByYearTerm = {};
let totalCredits = 0;
const seen = new Set();
for (const item of requirements) {
const label = normalizeLabel(item.label);
if (!label) continue; // skip empty rows