Britzzy commited on
Commit
d3222bc
·
1 Parent(s): 6886724

feat: add Neon PostgreSQL support via DATABASE_URL + Fly.io deployment config

Browse files
Files changed (6) hide show
  1. .dockerignore +10 -0
  2. Dockerfile +2 -4
  3. backend/config.py +6 -1
  4. backend/database.py +426 -231
  5. fly.toml +38 -0
  6. requirements.txt +1 -0
.dockerignore ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ __pycache__
2
+ *.pyc
3
+ .git
4
+ .gitignore
5
+ .env
6
+ venv
7
+ .venv
8
+ data/
9
+ *.db
10
+ .DS_Store
Dockerfile CHANGED
@@ -11,13 +11,11 @@ RUN pip install --no-cache-dir -r requirements.txt
11
 
12
  COPY . .
13
 
14
- ENV PORT=7860
15
  ENV DATA_DIR=/data
16
- ENV JOBLIN_JWT_SECRET=huggingface-joblin-secret-2026
17
- ENV JOBLIN_CRON_TOKEN=rSgdxzhCEZzTe5cVfXpBAVOEBeE6UpAWMP89AosAh4M
18
 
19
  RUN mkdir -p /data/generated
20
 
21
- EXPOSE 7860
22
 
23
  CMD uvicorn backend.main:app --host 0.0.0.0 --port ${PORT}
 
11
 
12
  COPY . .
13
 
14
+ ENV PORT=8080
15
  ENV DATA_DIR=/data
 
 
16
 
17
  RUN mkdir -p /data/generated
18
 
19
+ EXPOSE 8080
20
 
21
  CMD uvicorn backend.main:app --host 0.0.0.0 --port ${PORT}
backend/config.py CHANGED
@@ -10,7 +10,12 @@ TEMPLATES_DIR = BASE_DIR / "backend" / "templates"
10
  DATA_DIR.mkdir(parents=True, exist_ok=True)
11
  TEMPLATES_DIR.mkdir(parents=True, exist_ok=True)
12
 
13
- DATABASE_PATH = DATA_DIR / "joblin.db"
 
 
 
 
 
14
  GENERATED_DIR = DATA_DIR / "generated"
15
  GENERATED_DIR.mkdir(parents=True, exist_ok=True)
16
 
 
10
  DATA_DIR.mkdir(parents=True, exist_ok=True)
11
  TEMPLATES_DIR.mkdir(parents=True, exist_ok=True)
12
 
13
+ _DATABASE_URL_ENV = os.environ.get("DATABASE_URL", "")
14
+ if _DATABASE_URL_ENV:
15
+ DATABASE_PATH = None # PostgreSQL — no local SQLite file
16
+ else:
17
+ DATABASE_PATH = DATA_DIR / "joblin.db"
18
+
19
  GENERATED_DIR = DATA_DIR / "generated"
20
  GENERATED_DIR.mkdir(parents=True, exist_ok=True)
21
 
backend/database.py CHANGED
@@ -1,105 +1,286 @@
1
  import hashlib
2
  import secrets
3
- import sqlite3
4
  import json
5
  from contextlib import contextmanager
6
  from datetime import datetime, timedelta, timezone
7
  from backend.config import DATABASE_PATH
8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
 
10
- def init_db():
11
- with get_db() as conn:
12
- conn.executescript("""
13
- CREATE TABLE IF NOT EXISTS users (
14
- id INTEGER PRIMARY KEY AUTOINCREMENT,
15
- email TEXT UNIQUE NOT NULL,
16
- password_hash TEXT NOT NULL,
17
- name TEXT DEFAULT '',
18
- is_admin INTEGER DEFAULT 0,
19
- created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
20
- );
21
-
22
- CREATE TABLE IF NOT EXISTS api_keys (
23
- id INTEGER PRIMARY KEY AUTOINCREMENT,
24
- user_id INTEGER NOT NULL,
25
- provider TEXT NOT NULL,
26
- api_key TEXT NOT NULL,
27
- UNIQUE(user_id, provider)
28
- );
29
-
30
- CREATE TABLE IF NOT EXISTS user_cv (
31
- id INTEGER PRIMARY KEY AUTOINCREMENT,
32
- user_id INTEGER UNIQUE NOT NULL,
33
- cv_json TEXT NOT NULL,
34
- updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
35
- );
36
-
37
- -- Global shared job pool (all scraped jobs go here)
38
- CREATE TABLE IF NOT EXISTS global_jobs (
39
- id INTEGER PRIMARY KEY AUTOINCREMENT,
40
- title TEXT NOT NULL,
41
- company TEXT,
42
- location TEXT,
43
- description TEXT,
44
- url TEXT UNIQUE,
45
- source TEXT,
46
- board_category TEXT,
47
- job_category TEXT DEFAULT 'other',
48
- posted_date TEXT,
49
- date_found TEXT DEFAULT (datetime('now')),
50
- is_active INTEGER DEFAULT 1,
51
- is_graduate INTEGER DEFAULT 0,
52
- has_full_info INTEGER DEFAULT 0,
53
- match_score REAL DEFAULT 0
54
- );
55
-
56
- -- User settings (default API toggle, etc.)
57
- CREATE TABLE IF NOT EXISTS user_settings (
58
- user_id INTEGER PRIMARY KEY,
59
- use_default_api INTEGER DEFAULT 1
60
- );
61
-
62
- -- Per-user job interactions (bridge table)
63
- CREATE TABLE IF NOT EXISTS user_job_links (
64
- id INTEGER PRIMARY KEY AUTOINCREMENT,
65
- user_id INTEGER NOT NULL,
66
- job_id INTEGER NOT NULL,
67
- status TEXT DEFAULT 'new',
68
- tailored_cv_path TEXT,
69
- tailored_cover_path TEXT,
70
- created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
71
- UNIQUE(user_id, job_id)
72
- );
73
-
74
- -- Password reset tokens
75
- CREATE TABLE IF NOT EXISTS reset_tokens (
76
- id INTEGER PRIMARY KEY AUTOINCREMENT,
77
- user_id INTEGER NOT NULL,
78
- token_hash TEXT NOT NULL,
79
- expires_at TIMESTAMP NOT NULL,
80
- used INTEGER DEFAULT 0,
81
- created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
82
- FOREIGN KEY (user_id) REFERENCES users(id)
83
- );
84
-
85
- -- Job category definitions
86
- CREATE TABLE IF NOT EXISTS job_categories (
87
- id INTEGER PRIMARY KEY AUTOINCREMENT,
88
- slug TEXT UNIQUE NOT NULL,
89
- name TEXT NOT NULL,
90
- icon TEXT DEFAULT '',
91
- keywords TEXT NOT NULL,
92
- color TEXT DEFAULT '#059669'
93
- );
94
- """)
95
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
96
  try:
97
- conn.execute("ALTER TABLE users ADD COLUMN is_admin INTEGER DEFAULT 0")
98
- except sqlite3.OperationalError:
99
- pass
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
100
 
101
- _seed_categories(conn)
102
- _seed_admin_user(conn)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
103
 
104
 
105
  def _seed_categories(conn):
@@ -125,10 +306,14 @@ def _seed_categories(conn):
125
  ("content-writing", "Content & Writing", "edit", "content writer,copywriter,technical writer,editor,proofreader,content strategist,seo writer,ghostwriter,blog writer,writer,content creator,journalist,editorial", "#ec4899"),
126
  ]
127
  for slug, name, icon, keywords, color in categories:
128
- conn.execute(
129
  "INSERT OR IGNORE INTO job_categories (slug, name, icon, keywords, color) VALUES (?, ?, ?, ?, ?)",
130
- (slug, name, icon, keywords, color),
131
  )
 
 
 
 
132
 
133
 
134
  def _seed_admin_user(conn):
@@ -136,23 +321,16 @@ def _seed_admin_user(conn):
136
  password = "Lawrencium-103@"
137
  import bcrypt as _bcrypt
138
  pw_hash = _bcrypt.hashpw(password.encode("utf-8"), _bcrypt.gensalt()).decode("utf-8")
139
- conn.execute(
140
  "INSERT OR IGNORE INTO users (email, password_hash, name, is_admin) VALUES (?, ?, ?, 1)",
141
- (email, pw_hash, "Super Admin"),
142
  )
143
- conn.execute("UPDATE users SET is_admin = 1, password_hash = ? WHERE email = ?", (pw_hash, email))
144
-
145
-
146
- @contextmanager
147
- def get_db():
148
- conn = sqlite3.connect(str(DATABASE_PATH))
149
- conn.row_factory = sqlite3.Row
150
- conn.execute("PRAGMA foreign_keys = ON")
151
- try:
152
- yield conn
153
- conn.commit()
154
- finally:
155
- conn.close()
156
 
157
 
158
  # ── Users ───────────────────────────────────────────────────────────────────
@@ -160,25 +338,28 @@ def get_db():
160
  def create_user(email: str, password_hash: str, name: str = "") -> dict | None:
161
  with get_db() as conn:
162
  try:
163
- cur = conn.execute(
 
164
  "INSERT INTO users (email, password_hash, name) VALUES (?, ?, ?)",
165
  (email, password_hash, name),
166
  )
167
- return {"id": cur.lastrowid, "email": email, "name": name}
168
- except sqlite3.IntegrityError:
 
 
169
  return None
170
 
171
 
172
  def get_user_by_email(email: str) -> dict | None:
173
  with get_db() as conn:
174
- row = conn.execute("SELECT * FROM users WHERE email = ?", (email,)).fetchone()
175
- return dict(row) if row else None
176
 
177
 
178
  def get_user_by_id(user_id: int) -> dict | None:
179
  with get_db() as conn:
180
- row = conn.execute("SELECT id, email, name, is_admin, created_at FROM users WHERE id = ?", (user_id,)).fetchone()
181
- return dict(row) if row else None
182
 
183
 
184
  # ── Password Reset ──────────────────────────────────────────────────────────
@@ -191,36 +372,32 @@ def create_reset_token(email: str) -> str | None:
191
  token_hash = hashlib.sha256(raw_token.encode()).hexdigest()
192
  expires_at = (datetime.now(timezone.utc) + timedelta(minutes=15)).isoformat()
193
  with get_db() as conn:
194
- conn.execute(
195
- "DELETE FROM reset_tokens WHERE user_id = ?", (user["id"],)
196
- )
197
- conn.execute(
198
- "INSERT INTO reset_tokens (user_id, token_hash, expires_at) VALUES (?, ?, ?)",
199
- (user["id"], token_hash, expires_at),
200
- )
201
  return raw_token
202
 
203
 
204
  def get_valid_token(raw_token: str) -> dict | None:
205
  token_hash = hashlib.sha256(raw_token.encode()).hexdigest()
206
  with get_db() as conn:
207
- row = conn.execute(
208
- """SELECT rt.*, u.email FROM reset_tokens rt
 
209
  JOIN users u ON u.id = rt.user_id
210
- WHERE rt.token_hash = ? AND rt.used = 0 AND rt.expires_at > datetime('now')""",
211
  (token_hash,),
212
- ).fetchone()
213
- return dict(row) if row else None
214
 
215
 
216
  def mark_token_used(token_id: int) -> None:
217
  with get_db() as conn:
218
- conn.execute("UPDATE reset_tokens SET used = 1 WHERE id = ?", (token_id,))
219
 
220
 
221
  def update_password(user_id: int, password_hash: str) -> None:
222
  with get_db() as conn:
223
- conn.execute("UPDATE users SET password_hash = ? WHERE id = ?", (password_hash, user_id))
224
 
225
 
226
  # ── API Keys ────────────────────────────────────────────────────────────────
@@ -228,53 +405,53 @@ def update_password(user_id: int, password_hash: str) -> None:
228
  def save_api_keys(user_id: int, keys: dict) -> None:
229
  with get_db() as conn:
230
  for provider, api_key in keys.items():
231
- conn.execute(
232
- """INSERT INTO api_keys (user_id, provider, api_key)
233
- VALUES (?, ?, ?)
234
- ON CONFLICT(user_id, provider) DO UPDATE SET api_key = excluded.api_key""",
235
  (user_id, provider, api_key),
236
  )
237
 
238
 
239
  def get_user_settings(user_id: int) -> dict:
240
  with get_db() as conn:
241
- row = conn.execute(
242
- "SELECT use_default_api FROM user_settings WHERE user_id = ?", (user_id,)
243
- ).fetchone()
244
- if not row:
245
- conn.execute("INSERT OR IGNORE INTO user_settings (user_id, use_default_api) VALUES (?, 1)", (user_id,))
 
 
 
 
 
246
  return {"use_default_api": True}
247
- return {"use_default_api": bool(row["use_default_api"])}
248
 
249
 
250
  def save_user_settings(user_id: int, settings: dict) -> None:
251
  use_default = settings.get("use_default_api", True)
252
  with get_db() as conn:
253
- conn.execute(
254
- """INSERT INTO user_settings (user_id, use_default_api)
255
- VALUES (?, ?)
256
- ON CONFLICT(user_id) DO UPDATE SET use_default_api = excluded.use_default_api""",
257
  (user_id, 1 if use_default else 0),
258
  )
259
 
260
 
261
  def get_api_keys(user_id: int) -> dict:
262
  with get_db() as conn:
263
- rows = conn.execute(
264
- "SELECT provider, api_key FROM api_keys WHERE user_id = ?", (user_id,)
265
- ).fetchall()
266
  return {r["provider"]: r["api_key"] for r in rows}
267
 
268
 
269
  def get_effective_api_keys(user_id: int) -> dict:
270
- """Return user's API keys. If use_default_api is true, use default NVIDIA key (OpenRouter)."""
271
  from backend.config import DEFAULT_NVIDIA_KEY
272
  keys = get_api_keys(user_id)
273
  settings = get_user_settings(user_id)
274
-
275
  if settings.get("use_default_api", True):
276
  keys["nvidia"] = DEFAULT_NVIDIA_KEY
277
-
278
  return keys
279
 
280
 
@@ -282,20 +459,18 @@ def get_effective_api_keys(user_id: int) -> dict:
282
 
283
  def save_cv(user_id: int, cv_json: str) -> None:
284
  with get_db() as conn:
285
- conn.execute(
286
- """INSERT INTO user_cv (user_id, cv_json)
287
- VALUES (?, ?)
288
- ON CONFLICT(user_id) DO UPDATE SET cv_json = excluded.cv_json, updated_at = datetime('now')""",
289
  (user_id, cv_json),
290
  )
291
 
292
 
293
  def get_cv(user_id: int) -> str | None:
294
  with get_db() as conn:
295
- row = conn.execute(
296
- "SELECT cv_json FROM user_cv WHERE user_id = ?", (user_id,)
297
- ).fetchone()
298
- return row["cv_json"] if row else None
299
 
300
 
301
  def get_cv_default() -> dict:
@@ -320,28 +495,30 @@ def save_global_jobs(jobs: list[dict]) -> int:
320
  len(job.get("description", "") or "") > 100
321
  and job.get("company", "")
322
  ) else 0
323
- conn.execute(
324
- """INSERT OR IGNORE INTO global_jobs
325
- (title, company, location, description, url, source, board_category,
326
- job_category, posted_date, has_full_info, is_graduate)
327
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
328
- (
329
- job.get("title", ""),
330
- job.get("company", ""),
331
- job.get("location", ""),
332
- (job.get("description", "") or "")[:2000],
333
- job.get("url", ""),
334
- job.get("source", ""),
335
- job.get("category", ""),
336
- category,
337
- job.get("posted_date", ""),
338
- has_full_info,
339
- is_graduate,
340
- ),
341
  )
342
- if conn.total_changes > 0:
343
- saved += 1
344
- except Exception as e:
 
 
 
 
 
 
 
345
  pass
346
  return saved
347
 
@@ -364,10 +541,8 @@ def classify_job_title(title: str, description: str = "") -> str:
364
 
365
  def _is_graduate_job(title: str, description: str, category: str = "") -> int:
366
  text = f"{title} {description}".lower()
367
-
368
  if category == "graduate-entry":
369
  return 1
370
-
371
  strong = ["graduate trainee", "graduate intern", "graduate programme",
372
  "graduate program", "nysc", "corper", "corps member",
373
  "fresh graduate", "recent graduate", "entry level", "entry-level",
@@ -376,7 +551,6 @@ def _is_graduate_job(title: str, description: str, category: str = "") -> int:
376
  for kw in strong:
377
  if kw in text:
378
  return 1
379
-
380
  moderate = ["graduate", "intern", "internship", "trainee",
381
  "junior", "apprentice", "apprenticeship",
382
  "early career", "emerging talent", "newly qualified",
@@ -391,7 +565,6 @@ def _is_graduate_job(title: str, description: str, category: str = "") -> int:
391
  if any(s in text for s in senior):
392
  continue
393
  return 1
394
-
395
  return 0
396
 
397
 
@@ -422,20 +595,21 @@ def get_global_jobs(
422
  params.append(1 if is_graduate else 0)
423
 
424
  order = "date_found DESC, posted_date DESC" if sort == "date" else "match_score DESC, date_found DESC"
425
- query = f"SELECT * FROM global_jobs WHERE {' AND '.join(conditions)} ORDER BY {order} LIMIT ? OFFSET ?"
426
- params.extend([limit, offset])
427
- rows = conn.execute(query, params).fetchall()
428
- total = conn.execute(
429
- f"SELECT COUNT(*) FROM global_jobs WHERE {' AND '.join(conditions)}",
430
- params[:-2] if params[:-2] else [],
431
- ).fetchone()[0]
 
432
  return [dict(r) for r in rows], total
433
 
434
 
435
  def get_global_job(job_id: int):
436
  with get_db() as conn:
437
- row = conn.execute("SELECT * FROM global_jobs WHERE id = ?", (job_id,)).fetchone()
438
- return dict(row) if row else None
439
 
440
 
441
  # ── User-Job Links ──────────────────────────────────────────────────────────
@@ -443,10 +617,14 @@ def get_global_job(job_id: int):
443
  def link_user_job(user_id: int, job_id: int) -> bool:
444
  with get_db() as conn:
445
  try:
446
- conn.execute(
447
  "INSERT OR IGNORE INTO user_job_links (user_id, job_id) VALUES (?, ?)",
448
- (user_id, job_id),
449
  )
 
 
 
 
450
  return True
451
  except Exception:
452
  return False
@@ -454,20 +632,22 @@ def link_user_job(user_id: int, job_id: int) -> bool:
454
 
455
  def get_user_job_link(user_id: int, job_id: int):
456
  with get_db() as conn:
457
- row = conn.execute(
 
458
  """SELECT ujl.*, gj.title, gj.company, gj.description, gj.url, gj.source,
459
  gj.job_category, gj.posted_date, gj.match_score
460
  FROM user_job_links ujl
461
  JOIN global_jobs gj ON gj.id = ujl.job_id
462
  WHERE ujl.user_id = ? AND ujl.job_id = ?""",
463
  (user_id, job_id),
464
- ).fetchone()
465
- return dict(row) if row else None
466
 
467
 
468
  def get_user_linked_jobs(user_id: int, limit: int = 50):
469
  with get_db() as conn:
470
- rows = conn.execute(
 
471
  """SELECT ujl.*, gj.title, gj.company, gj.description, gj.url, gj.source,
472
  gj.job_category, gj.posted_date, gj.match_score
473
  FROM user_job_links ujl
@@ -481,10 +661,9 @@ def get_user_linked_jobs(user_id: int, limit: int = 50):
481
 
482
  def update_link_tailoring(user_id: int, job_id: int, cv_path: str, cover_path: str):
483
  with get_db() as conn:
484
- conn.execute(
485
- """UPDATE user_job_links SET
486
- tailored_cv_path = ?, tailored_cover_path = ?, status = 'tailored'
487
- WHERE user_id = ? AND job_id = ?""",
488
  (cv_path, cover_path, user_id, job_id),
489
  )
490
 
@@ -493,7 +672,7 @@ def update_link_tailoring(user_id: int, job_id: int, cv_path: str, cover_path: s
493
 
494
  def get_categories():
495
  with get_db() as conn:
496
- rows = conn.execute("SELECT * FROM job_categories ORDER BY name").fetchall()
497
  return [dict(r) for r in rows]
498
 
499
 
@@ -501,37 +680,48 @@ def get_categories():
501
 
502
  def get_global_stats():
503
  with get_db() as conn:
504
- total = conn.execute("SELECT COUNT(*) FROM global_jobs WHERE is_active = 1").fetchone()[0]
505
- by_cat = conn.execute(
506
- "SELECT job_category, COUNT(*) as cnt FROM global_jobs WHERE is_active = 1 GROUP BY job_category ORDER BY cnt DESC"
 
 
 
507
  ).fetchall()
508
- by_source = conn.execute(
509
- "SELECT source, COUNT(*) as cnt FROM global_jobs WHERE is_active = 1 GROUP BY source ORDER BY cnt DESC"
 
 
510
  ).fetchall()
511
- recent = conn.execute(
512
- "SELECT COUNT(*) FROM global_jobs WHERE is_active = 1 AND date_found >= datetime('now', '-1 day')"
513
- ).fetchone()[0]
514
 
515
- last_found = conn.execute(
516
- "SELECT MAX(date_found) as last_scraped FROM global_jobs"
517
- ).fetchone()["last_scraped"]
 
 
518
 
519
- new_since_midnight = conn.execute(
520
- "SELECT COUNT(*) FROM global_jobs WHERE is_active = 1 AND date_found >= datetime('now', 'start of day')"
521
- ).fetchone()[0]
522
 
523
- new_since_yesterday = conn.execute(
524
- "SELECT COUNT(*) FROM global_jobs WHERE is_active = 1 AND date_found >= datetime('now', '-1 day')"
525
- ).fetchone()[0]
 
 
 
 
 
 
 
 
526
 
527
  return {
528
- "total": total,
529
  "by_category": {r["job_category"]: r["cnt"] for r in by_cat},
530
  "by_source": {r["source"]: r["cnt"] for r in by_source},
531
- "recent_24h": recent,
532
- "last_scraped": last_found,
533
- "new_today": new_since_midnight,
534
- "new_24h": new_since_yesterday,
535
  }
536
 
537
 
@@ -539,13 +729,18 @@ def get_global_stats():
539
 
540
  def deactivate_old_jobs(days: int = 7):
541
  with get_db() as conn:
542
- conn.execute(
543
- "UPDATE global_jobs SET is_active = 0 WHERE date_found < datetime('now', ?)",
544
- (f"-{days} days",),
545
  )
546
- removed = conn.total_changes
547
- # Also clean orphaned user_job_links
548
- conn.execute(
549
- "DELETE FROM user_job_links WHERE job_id IN (SELECT id FROM global_jobs WHERE is_active = 0)"
 
 
 
 
 
550
  )
551
  return removed
 
1
  import hashlib
2
  import secrets
 
3
  import json
4
  from contextlib import contextmanager
5
  from datetime import datetime, timedelta, timezone
6
  from backend.config import DATABASE_PATH
7
 
8
+ import os as _os
9
+ _DATABASE_URL = _os.environ.get("DATABASE_URL", "")
10
+
11
+ if _DATABASE_URL:
12
+ import psycopg2 as _psycopg2
13
+ from psycopg2.extras import RealDictCursor as _RealDictCursor
14
+ else:
15
+ import sqlite3 as _sqlite3
16
+
17
+
18
+ def _is_pg():
19
+ return bool(_DATABASE_URL)
20
+
21
+
22
+ def _p():
23
+ return "%s" if _is_pg() else "?"
24
+
25
+
26
+ def _now_sql():
27
+ return "CURRENT_TIMESTAMP" if _is_pg() else "datetime('now')"
28
+
29
+
30
+ def _now_offset_sql(days_expr: str):
31
+ if _is_pg():
32
+ return f"CURRENT_TIMESTAMP - INTERVAL '{days_expr}'"
33
+ return f"datetime('now', '{days_expr}')"
34
+
35
+
36
+ def _now_offset_param(days: int):
37
+ if _is_pg():
38
+ return f"CURRENT_TIMESTAMP - INTERVAL '{days} days'"
39
+ return f"datetime('now', '-{days} days')"
40
+
41
+
42
+ def _start_of_day_sql():
43
+ if _is_pg():
44
+ return "DATE_TRUNC('day', CURRENT_TIMESTAMP)"
45
+ return "datetime('now', 'start of day')"
46
+
47
+
48
+ def _fix_sql(sql: str) -> str:
49
+ if not _is_pg():
50
+ return sql
51
+ sql = sql.replace("?", "%s")
52
+ sql = sql.replace("excluded.", "EXCLUDED.")
53
+ sql = sql.replace("INSERT OR IGNORE INTO", "INSERT INTO")
54
+ return sql
55
+
56
+
57
+ def _insert_on_conflict(sql: str, conflict_cols: str) -> str:
58
+ if not _is_pg():
59
+ return sql
60
+ stripped = sql.replace("INSERT OR IGNORE INTO", "INSERT INTO").rstrip(";")
61
+ return stripped + f" ON CONFLICT ({conflict_cols}) DO NOTHING"
62
+
63
+
64
+ # DDL for each engine
65
+ _PG_DDL = """
66
+ CREATE TABLE IF NOT EXISTS users (
67
+ id SERIAL PRIMARY KEY,
68
+ email TEXT UNIQUE NOT NULL,
69
+ password_hash TEXT NOT NULL,
70
+ name TEXT DEFAULT '',
71
+ is_admin INTEGER DEFAULT 0,
72
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
73
+ );
74
+
75
+ CREATE TABLE IF NOT EXISTS api_keys (
76
+ id SERIAL PRIMARY KEY,
77
+ user_id INTEGER NOT NULL,
78
+ provider TEXT NOT NULL,
79
+ api_key TEXT NOT NULL,
80
+ UNIQUE(user_id, provider)
81
+ );
82
+
83
+ CREATE TABLE IF NOT EXISTS user_cv (
84
+ id SERIAL PRIMARY KEY,
85
+ user_id INTEGER UNIQUE NOT NULL,
86
+ cv_json TEXT NOT NULL,
87
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
88
+ );
89
+
90
+ CREATE TABLE IF NOT EXISTS global_jobs (
91
+ id SERIAL PRIMARY KEY,
92
+ title TEXT NOT NULL,
93
+ company TEXT,
94
+ location TEXT,
95
+ description TEXT,
96
+ url TEXT UNIQUE,
97
+ source TEXT,
98
+ board_category TEXT,
99
+ job_category TEXT DEFAULT 'other',
100
+ posted_date TEXT,
101
+ date_found TEXT DEFAULT CURRENT_TIMESTAMP,
102
+ is_active INTEGER DEFAULT 1,
103
+ is_graduate INTEGER DEFAULT 0,
104
+ has_full_info INTEGER DEFAULT 0,
105
+ match_score REAL DEFAULT 0
106
+ );
107
+
108
+ CREATE TABLE IF NOT EXISTS user_settings (
109
+ user_id INTEGER PRIMARY KEY,
110
+ use_default_api INTEGER DEFAULT 1
111
+ );
112
+
113
+ CREATE TABLE IF NOT EXISTS user_job_links (
114
+ id SERIAL PRIMARY KEY,
115
+ user_id INTEGER NOT NULL,
116
+ job_id INTEGER NOT NULL,
117
+ status TEXT DEFAULT 'new',
118
+ tailored_cv_path TEXT,
119
+ tailored_cover_path TEXT,
120
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
121
+ UNIQUE(user_id, job_id)
122
+ );
123
+
124
+ CREATE TABLE IF NOT EXISTS reset_tokens (
125
+ id SERIAL PRIMARY KEY,
126
+ user_id INTEGER NOT NULL,
127
+ token_hash TEXT NOT NULL,
128
+ expires_at TIMESTAMP NOT NULL,
129
+ used INTEGER DEFAULT 0,
130
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
131
+ FOREIGN KEY (user_id) REFERENCES users(id)
132
+ );
133
+
134
+ CREATE TABLE IF NOT EXISTS job_categories (
135
+ id SERIAL PRIMARY KEY,
136
+ slug TEXT UNIQUE NOT NULL,
137
+ name TEXT NOT NULL,
138
+ icon TEXT DEFAULT '',
139
+ keywords TEXT NOT NULL,
140
+ color TEXT DEFAULT '#059669'
141
+ );
142
+ """
143
+
144
+ _SQLITE_DDL = """
145
+ CREATE TABLE IF NOT EXISTS users (
146
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
147
+ email TEXT UNIQUE NOT NULL,
148
+ password_hash TEXT NOT NULL,
149
+ name TEXT DEFAULT '',
150
+ is_admin INTEGER DEFAULT 0,
151
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
152
+ );
153
+
154
+ CREATE TABLE IF NOT EXISTS api_keys (
155
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
156
+ user_id INTEGER NOT NULL,
157
+ provider TEXT NOT NULL,
158
+ api_key TEXT NOT NULL,
159
+ UNIQUE(user_id, provider)
160
+ );
161
+
162
+ CREATE TABLE IF NOT EXISTS user_cv (
163
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
164
+ user_id INTEGER UNIQUE NOT NULL,
165
+ cv_json TEXT NOT NULL,
166
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
167
+ );
168
+
169
+ CREATE TABLE IF NOT EXISTS global_jobs (
170
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
171
+ title TEXT NOT NULL,
172
+ company TEXT,
173
+ location TEXT,
174
+ description TEXT,
175
+ url TEXT UNIQUE,
176
+ source TEXT,
177
+ board_category TEXT,
178
+ job_category TEXT DEFAULT 'other',
179
+ posted_date TEXT,
180
+ date_found TEXT DEFAULT (datetime('now')),
181
+ is_active INTEGER DEFAULT 1,
182
+ is_graduate INTEGER DEFAULT 0,
183
+ has_full_info INTEGER DEFAULT 0,
184
+ match_score REAL DEFAULT 0
185
+ );
186
+
187
+ CREATE TABLE IF NOT EXISTS user_settings (
188
+ user_id INTEGER PRIMARY KEY,
189
+ use_default_api INTEGER DEFAULT 1
190
+ );
191
+
192
+ CREATE TABLE IF NOT EXISTS user_job_links (
193
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
194
+ user_id INTEGER NOT NULL,
195
+ job_id INTEGER NOT NULL,
196
+ status TEXT DEFAULT 'new',
197
+ tailored_cv_path TEXT,
198
+ tailored_cover_path TEXT,
199
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
200
+ UNIQUE(user_id, job_id)
201
+ );
202
+
203
+ CREATE TABLE IF NOT EXISTS reset_tokens (
204
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
205
+ user_id INTEGER NOT NULL,
206
+ token_hash TEXT NOT NULL,
207
+ expires_at TIMESTAMP NOT NULL,
208
+ used INTEGER DEFAULT 0,
209
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
210
+ FOREIGN KEY (user_id) REFERENCES users(id)
211
+ );
212
+
213
+ CREATE TABLE IF NOT EXISTS job_categories (
214
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
215
+ slug TEXT UNIQUE NOT NULL,
216
+ name TEXT NOT NULL,
217
+ icon TEXT DEFAULT '',
218
+ keywords TEXT NOT NULL,
219
+ color TEXT DEFAULT '#059669'
220
+ );
221
+ """
222
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
223
 
224
+ @contextmanager
225
+ def get_db():
226
+ if _is_pg():
227
+ conn = _psycopg2.connect(_DATABASE_URL, cursor_factory=_RealDictCursor)
228
+ try:
229
+ yield conn
230
+ conn.commit()
231
+ except Exception:
232
+ conn.rollback()
233
+ raise
234
+ finally:
235
+ conn.close()
236
+ else:
237
+ conn = _sqlite3.connect(str(DATABASE_PATH))
238
+ conn.row_factory = _sqlite3.Row
239
+ conn.execute("PRAGMA foreign_keys = ON")
240
  try:
241
+ yield conn
242
+ conn.commit()
243
+ finally:
244
+ conn.close()
245
+
246
+
247
+ def _exec(conn, sql: str, params=()):
248
+ return conn.execute(_fix_sql(sql), params)
249
+
250
+
251
+ def _exec_lastid(conn, sql: str, params=()):
252
+ if _is_pg():
253
+ cur = conn.execute(_fix_sql(sql) + " RETURNING id", params)
254
+ row = cur.fetchone()
255
+ return row["id"] if row else None
256
+ cur = conn.execute(sql, params)
257
+ return cur.lastrowid
258
+
259
+
260
+ def _total_changes(conn):
261
+ if _is_pg():
262
+ return conn.statusmessage.split()[-1] if conn.statusmessage else 0
263
+ return conn.total_changes
264
 
265
+
266
+ # ── Init ─────────────────────────────────────────────────────────────────────
267
+
268
+ def init_db():
269
+ if _is_pg():
270
+ with get_db() as conn:
271
+ with conn.cursor() as cur:
272
+ cur.execute(_PG_DDL)
273
+ _seed_categories(conn)
274
+ _seed_admin_user(conn)
275
+ else:
276
+ with get_db() as conn:
277
+ conn.executescript(_SQLITE_DDL)
278
+ try:
279
+ conn.execute("ALTER TABLE users ADD COLUMN is_admin INTEGER DEFAULT 0")
280
+ except _sqlite3.OperationalError:
281
+ pass
282
+ _seed_categories(conn)
283
+ _seed_admin_user(conn)
284
 
285
 
286
  def _seed_categories(conn):
 
306
  ("content-writing", "Content & Writing", "edit", "content writer,copywriter,technical writer,editor,proofreader,content strategist,seo writer,ghostwriter,blog writer,writer,content creator,journalist,editorial", "#ec4899"),
307
  ]
308
  for slug, name, icon, keywords, color in categories:
309
+ sql = _insert_on_conflict(
310
  "INSERT OR IGNORE INTO job_categories (slug, name, icon, keywords, color) VALUES (?, ?, ?, ?, ?)",
311
+ "slug",
312
  )
313
+ if _is_pg():
314
+ conn.execute(_fix_sql(sql), (slug, name, icon, keywords, color))
315
+ else:
316
+ conn.execute(sql, (slug, name, icon, keywords, color))
317
 
318
 
319
  def _seed_admin_user(conn):
 
321
  password = "Lawrencium-103@"
322
  import bcrypt as _bcrypt
323
  pw_hash = _bcrypt.hashpw(password.encode("utf-8"), _bcrypt.gensalt()).decode("utf-8")
324
+ sql = _insert_on_conflict(
325
  "INSERT OR IGNORE INTO users (email, password_hash, name, is_admin) VALUES (?, ?, ?, 1)",
326
+ "email",
327
  )
328
+ if _is_pg():
329
+ conn.execute(_fix_sql(sql), (email, pw_hash, "Super Admin"))
330
+ conn.execute("UPDATE users SET is_admin = 1, password_hash = %s WHERE email = %s", (pw_hash, email))
331
+ else:
332
+ conn.execute(sql, (email, pw_hash, "Super Admin"))
333
+ conn.execute("UPDATE users SET is_admin = 1, password_hash = ? WHERE email = ?", (pw_hash, email))
 
 
 
 
 
 
 
334
 
335
 
336
  # ── Users ───────────────────────────────────────────────────────────────────
 
338
  def create_user(email: str, password_hash: str, name: str = "") -> dict | None:
339
  with get_db() as conn:
340
  try:
341
+ uid = _exec_lastid(
342
+ conn,
343
  "INSERT INTO users (email, password_hash, name) VALUES (?, ?, ?)",
344
  (email, password_hash, name),
345
  )
346
+ if uid is None:
347
+ return None
348
+ return {"id": uid, "email": email, "name": name}
349
+ except Exception:
350
  return None
351
 
352
 
353
  def get_user_by_email(email: str) -> dict | None:
354
  with get_db() as conn:
355
+ rows = _exec(conn, "SELECT * FROM users WHERE email = ?", (email,)).fetchall()
356
+ return dict(rows[0]) if rows else None
357
 
358
 
359
  def get_user_by_id(user_id: int) -> dict | None:
360
  with get_db() as conn:
361
+ rows = _exec(conn, "SELECT id, email, name, is_admin, created_at FROM users WHERE id = ?", (user_id,)).fetchall()
362
+ return dict(rows[0]) if rows else None
363
 
364
 
365
  # ── Password Reset ──────────────────────────────────────────────────────────
 
372
  token_hash = hashlib.sha256(raw_token.encode()).hexdigest()
373
  expires_at = (datetime.now(timezone.utc) + timedelta(minutes=15)).isoformat()
374
  with get_db() as conn:
375
+ _exec(conn, "DELETE FROM reset_tokens WHERE user_id = ?", (user["id"],))
376
+ _exec(conn, "INSERT INTO reset_tokens (user_id, token_hash, expires_at) VALUES (?, ?, ?)", (user["id"], token_hash, expires_at))
 
 
 
 
 
377
  return raw_token
378
 
379
 
380
  def get_valid_token(raw_token: str) -> dict | None:
381
  token_hash = hashlib.sha256(raw_token.encode()).hexdigest()
382
  with get_db() as conn:
383
+ rows = _exec(
384
+ conn,
385
+ f"""SELECT rt.*, u.email FROM reset_tokens rt
386
  JOIN users u ON u.id = rt.user_id
387
+ WHERE rt.token_hash = ? AND rt.used = 0 AND rt.expires_at > {_now_sql()}""",
388
  (token_hash,),
389
+ ).fetchall()
390
+ return dict(rows[0]) if rows else None
391
 
392
 
393
  def mark_token_used(token_id: int) -> None:
394
  with get_db() as conn:
395
+ _exec(conn, "UPDATE reset_tokens SET used = 1 WHERE id = ?", (token_id,))
396
 
397
 
398
  def update_password(user_id: int, password_hash: str) -> None:
399
  with get_db() as conn:
400
+ _exec(conn, "UPDATE users SET password_hash = ? WHERE id = ?", (password_hash, user_id))
401
 
402
 
403
  # ── API Keys ────────────────────────────────────────────────────────────────
 
405
  def save_api_keys(user_id: int, keys: dict) -> None:
406
  with get_db() as conn:
407
  for provider, api_key in keys.items():
408
+ _exec(
409
+ conn,
410
+ "INSERT INTO api_keys (user_id, provider, api_key) VALUES (?, ?, ?) "
411
+ "ON CONFLICT(user_id, provider) DO UPDATE SET api_key = excluded.api_key",
412
  (user_id, provider, api_key),
413
  )
414
 
415
 
416
  def get_user_settings(user_id: int) -> dict:
417
  with get_db() as conn:
418
+ rows = _exec(conn, "SELECT use_default_api FROM user_settings WHERE user_id = ?", (user_id,)).fetchall()
419
+ if not rows:
420
+ sql = _insert_on_conflict(
421
+ "INSERT OR IGNORE INTO user_settings (user_id, use_default_api) VALUES (?, 1)",
422
+ "user_id",
423
+ )
424
+ if _is_pg():
425
+ conn.execute(_fix_sql(sql), (user_id,))
426
+ else:
427
+ conn.execute(sql, (user_id,))
428
  return {"use_default_api": True}
429
+ return {"use_default_api": bool(rows[0]["use_default_api"])}
430
 
431
 
432
  def save_user_settings(user_id: int, settings: dict) -> None:
433
  use_default = settings.get("use_default_api", True)
434
  with get_db() as conn:
435
+ _exec(
436
+ conn,
437
+ "INSERT INTO user_settings (user_id, use_default_api) VALUES (?, ?) "
438
+ "ON CONFLICT(user_id) DO UPDATE SET use_default_api = excluded.use_default_api",
439
  (user_id, 1 if use_default else 0),
440
  )
441
 
442
 
443
  def get_api_keys(user_id: int) -> dict:
444
  with get_db() as conn:
445
+ rows = _exec(conn, "SELECT provider, api_key FROM api_keys WHERE user_id = ?", (user_id,)).fetchall()
 
 
446
  return {r["provider"]: r["api_key"] for r in rows}
447
 
448
 
449
  def get_effective_api_keys(user_id: int) -> dict:
 
450
  from backend.config import DEFAULT_NVIDIA_KEY
451
  keys = get_api_keys(user_id)
452
  settings = get_user_settings(user_id)
 
453
  if settings.get("use_default_api", True):
454
  keys["nvidia"] = DEFAULT_NVIDIA_KEY
 
455
  return keys
456
 
457
 
 
459
 
460
  def save_cv(user_id: int, cv_json: str) -> None:
461
  with get_db() as conn:
462
+ _exec(
463
+ conn,
464
+ f"INSERT INTO user_cv (user_id, cv_json) VALUES (?, ?) "
465
+ f"ON CONFLICT(user_id) DO UPDATE SET cv_json = excluded.cv_json, updated_at = {_now_sql()}",
466
  (user_id, cv_json),
467
  )
468
 
469
 
470
  def get_cv(user_id: int) -> str | None:
471
  with get_db() as conn:
472
+ rows = _exec(conn, "SELECT cv_json FROM user_cv WHERE user_id = ?", (user_id,)).fetchall()
473
+ return rows[0]["cv_json"] if rows else None
 
 
474
 
475
 
476
  def get_cv_default() -> dict:
 
495
  len(job.get("description", "") or "") > 100
496
  and job.get("company", "")
497
  ) else 0
498
+ sql = "INSERT OR IGNORE INTO global_jobs (title, company, location, description, url, source, board_category, job_category, posted_date, has_full_info, is_graduate) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
499
+ p = (
500
+ job.get("title", ""),
501
+ job.get("company", ""),
502
+ job.get("location", ""),
503
+ (job.get("description", "") or "")[:2000],
504
+ job.get("url", ""),
505
+ job.get("source", ""),
506
+ job.get("category", ""),
507
+ category,
508
+ job.get("posted_date", ""),
509
+ has_full_info,
510
+ is_graduate,
 
 
 
 
 
511
  )
512
+ if _is_pg():
513
+ pg_sql = "INSERT INTO global_jobs (title, company, location, description, url, source, board_category, job_category, posted_date, has_full_info, is_graduate) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) ON CONFLICT (url) DO NOTHING"
514
+ cur = conn.execute(pg_sql, p)
515
+ if cur.rowcount > 0:
516
+ saved += 1
517
+ else:
518
+ conn.execute(sql, p)
519
+ if conn.total_changes > 0:
520
+ saved += 1
521
+ except Exception:
522
  pass
523
  return saved
524
 
 
541
 
542
  def _is_graduate_job(title: str, description: str, category: str = "") -> int:
543
  text = f"{title} {description}".lower()
 
544
  if category == "graduate-entry":
545
  return 1
 
546
  strong = ["graduate trainee", "graduate intern", "graduate programme",
547
  "graduate program", "nysc", "corper", "corps member",
548
  "fresh graduate", "recent graduate", "entry level", "entry-level",
 
551
  for kw in strong:
552
  if kw in text:
553
  return 1
 
554
  moderate = ["graduate", "intern", "internship", "trainee",
555
  "junior", "apprentice", "apprenticeship",
556
  "early career", "emerging talent", "newly qualified",
 
565
  if any(s in text for s in senior):
566
  continue
567
  return 1
 
568
  return 0
569
 
570
 
 
595
  params.append(1 if is_graduate else 0)
596
 
597
  order = "date_found DESC, posted_date DESC" if sort == "date" else "match_score DESC, date_found DESC"
598
+ where = " AND ".join(conditions)
599
+ query = f"SELECT * FROM global_jobs WHERE {where} ORDER BY {order} LIMIT ? OFFSET ?"
600
+ count_query = f"SELECT COUNT(*) as cnt FROM global_jobs WHERE {where}"
601
+
602
+ all_params = params + [limit, offset]
603
+ rows = _exec(conn, query, all_params).fetchall()
604
+ total_row = _exec(conn, count_query, params).fetchone()
605
+ total = total_row["cnt"] if total_row else 0
606
  return [dict(r) for r in rows], total
607
 
608
 
609
  def get_global_job(job_id: int):
610
  with get_db() as conn:
611
+ rows = _exec(conn, "SELECT * FROM global_jobs WHERE id = ?", (job_id,)).fetchall()
612
+ return dict(rows[0]) if rows else None
613
 
614
 
615
  # ── User-Job Links ──────────────────────────────────────────────────────────
 
617
  def link_user_job(user_id: int, job_id: int) -> bool:
618
  with get_db() as conn:
619
  try:
620
+ sql = _insert_on_conflict(
621
  "INSERT OR IGNORE INTO user_job_links (user_id, job_id) VALUES (?, ?)",
622
+ "user_id, job_id",
623
  )
624
+ if _is_pg():
625
+ conn.execute(_fix_sql(sql), (user_id, job_id))
626
+ else:
627
+ conn.execute(sql, (user_id, job_id))
628
  return True
629
  except Exception:
630
  return False
 
632
 
633
  def get_user_job_link(user_id: int, job_id: int):
634
  with get_db() as conn:
635
+ rows = _exec(
636
+ conn,
637
  """SELECT ujl.*, gj.title, gj.company, gj.description, gj.url, gj.source,
638
  gj.job_category, gj.posted_date, gj.match_score
639
  FROM user_job_links ujl
640
  JOIN global_jobs gj ON gj.id = ujl.job_id
641
  WHERE ujl.user_id = ? AND ujl.job_id = ?""",
642
  (user_id, job_id),
643
+ ).fetchall()
644
+ return dict(rows[0]) if rows else None
645
 
646
 
647
  def get_user_linked_jobs(user_id: int, limit: int = 50):
648
  with get_db() as conn:
649
+ rows = _exec(
650
+ conn,
651
  """SELECT ujl.*, gj.title, gj.company, gj.description, gj.url, gj.source,
652
  gj.job_category, gj.posted_date, gj.match_score
653
  FROM user_job_links ujl
 
661
 
662
  def update_link_tailoring(user_id: int, job_id: int, cv_path: str, cover_path: str):
663
  with get_db() as conn:
664
+ _exec(
665
+ conn,
666
+ "UPDATE user_job_links SET tailored_cv_path = ?, tailored_cover_path = ?, status = 'tailored' WHERE user_id = ? AND job_id = ?",
 
667
  (cv_path, cover_path, user_id, job_id),
668
  )
669
 
 
672
 
673
  def get_categories():
674
  with get_db() as conn:
675
+ rows = _exec(conn, "SELECT * FROM job_categories ORDER BY name").fetchall()
676
  return [dict(r) for r in rows]
677
 
678
 
 
680
 
681
  def get_global_stats():
682
  with get_db() as conn:
683
+ total = _exec(conn, "SELECT COUNT(*) as cnt FROM global_jobs WHERE is_active = 1").fetchone()
684
+ total_cnt = total["cnt"] if total else 0
685
+
686
+ by_cat = _exec(
687
+ conn,
688
+ "SELECT job_category, COUNT(*) as cnt FROM global_jobs WHERE is_active = 1 GROUP BY job_category ORDER BY cnt DESC",
689
  ).fetchall()
690
+
691
+ by_source = _exec(
692
+ conn,
693
+ "SELECT source, COUNT(*) as cnt FROM global_jobs WHERE is_active = 1 GROUP BY source ORDER BY cnt DESC",
694
  ).fetchall()
 
 
 
695
 
696
+ recent = _exec(
697
+ conn,
698
+ f"SELECT COUNT(*) as cnt FROM global_jobs WHERE is_active = 1 AND date_found >= {_now_offset_sql('-1 day')}",
699
+ ).fetchone()
700
+ recent_cnt = recent["cnt"] if recent else 0
701
 
702
+ last_row = _exec(conn, "SELECT MAX(date_found) as last_scraped FROM global_jobs").fetchone()
703
+ last_scraped = last_row["last_scraped"] if last_row else None
 
704
 
705
+ today = _exec(
706
+ conn,
707
+ f"SELECT COUNT(*) as cnt FROM global_jobs WHERE is_active = 1 AND date_found >= {_start_of_day_sql()}",
708
+ ).fetchone()
709
+ today_cnt = today["cnt"] if today else 0
710
+
711
+ yesterday = _exec(
712
+ conn,
713
+ f"SELECT COUNT(*) as cnt FROM global_jobs WHERE is_active = 1 AND date_found >= {_now_offset_sql('-1 day')}",
714
+ ).fetchone()
715
+ yesterday_cnt = yesterday["cnt"] if yesterday else 0
716
 
717
  return {
718
+ "total": total_cnt,
719
  "by_category": {r["job_category"]: r["cnt"] for r in by_cat},
720
  "by_source": {r["source"]: r["cnt"] for r in by_source},
721
+ "recent_24h": recent_cnt,
722
+ "last_scraped": last_scraped,
723
+ "new_today": today_cnt,
724
+ "new_24h": yesterday_cnt,
725
  }
726
 
727
 
 
729
 
730
  def deactivate_old_jobs(days: int = 7):
731
  with get_db() as conn:
732
+ _exec(
733
+ conn,
734
+ f"UPDATE global_jobs SET is_active = 0 WHERE date_found < {_now_offset_param(days)}",
735
  )
736
+ removed = _total_changes(conn)
737
+ if _is_pg() and isinstance(removed, str):
738
+ try:
739
+ removed = int(removed)
740
+ except (ValueError, TypeError):
741
+ removed = 0
742
+ _exec(
743
+ conn,
744
+ "DELETE FROM user_job_links WHERE job_id IN (SELECT id FROM global_jobs WHERE is_active = 0)",
745
  )
746
  return removed
fly.toml ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ app = "joblin"
2
+
3
+ kill_signal = "SIGINT"
4
+ kill_timeout = 5
5
+ processes = []
6
+
7
+ [env]
8
+ DATA_DIR = "/data"
9
+ PORT = "8080"
10
+
11
+ [experimental]
12
+ auto_rollback = true
13
+
14
+ [[services]]
15
+ http_checks = []
16
+ internal_port = 8080
17
+ processes = ["app"]
18
+ protocol = "tcp"
19
+ script_checks = []
20
+
21
+ [services.concurrency]
22
+ hard_limit = 25
23
+ soft_limit = 20
24
+ type = "connections"
25
+
26
+ [[services.ports]]
27
+ handlers = ["http"]
28
+ port = 80
29
+
30
+ [[services.ports]]
31
+ handlers = ["tls", "http"]
32
+ port = 443
33
+
34
+ [[services.tcp_checks]]
35
+ grace_period = "10s"
36
+ interval = "15s"
37
+ restart_limit = 0
38
+ timeout = "5s"
requirements.txt CHANGED
@@ -11,3 +11,4 @@ apscheduler>=3.10.0
11
  openpyxl>=3.1.0
12
  pydantic>=2.6.0
13
  python-dateutil>=2.8.0
 
 
11
  openpyxl>=3.1.0
12
  pydantic>=2.6.0
13
  python-dateutil>=2.8.0
14
+ psycopg2-binary>=2.9.0