feat: M1 — LinUCB bandit, RemotePolicy, Web Push, event bus
ML serving: - LinUCB contextual bandit (disjoint, d=5 features: hour_sin/cos, is_overdue, task_age, priority) - /score endpoint replaces stub random; /reward endpoint for online learning - Per-user model state persisted to disk as JSON (survives restarts) - venv at ml/serving/.venv; start with pnpm dev from ml/serving Recommender: - Todoist fetch now extracts features (is_overdue, task_age_days, priority) - RemotePolicy calls ml/serving with 3s timeout; falls back to RandomPolicy - Reward sent to /reward on feedback (done=+1, snooze=0, dismiss=-1) Web Push: - VAPID keys in config; push_subscriptions table in DB - POST/DELETE /api/push/subscribe; GET /api/push/vapid-public-key - Service worker (public/sw.js): push → showNotification, notificationclick → focus/open - "notify me" button on tip page; registers SW + subscribes on permission grant Event bus: - services/api/src/events/bus.ts: typed EventEmitter wrapper - Subjects: signals.tip.served, signals.tip.feedback, signals.task.synced - Same publish/subscribe API NATS JetStream will implement — swap is mechanical Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -14,6 +14,11 @@ ML_SERVING_URL=http://localhost:8000
|
|||||||
GOOGLE_CLIENT_ID=
|
GOOGLE_CLIENT_ID=
|
||||||
GOOGLE_CLIENT_SECRET=
|
GOOGLE_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# VAPID (Web Push) — generate: node -e "const wp=require('web-push');console.log(JSON.stringify(wp.generateVAPIDKeys()))"
|
||||||
|
VAPID_PUBLIC_KEY=
|
||||||
|
VAPID_PRIVATE_KEY=
|
||||||
|
VAPID_SUBJECT=mailto:you@example.com
|
||||||
|
|
||||||
# Todoist OAuth — https://developer.todoist.com/appconsole.html
|
# Todoist OAuth — https://developer.todoist.com/appconsole.html
|
||||||
TODOIST_CLIENT_ID=
|
TODOIST_CLIENT_ID=
|
||||||
TODOIST_CLIENT_SECRET=
|
TODOIST_CLIENT_SECRET=
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -11,6 +11,7 @@ build/
|
|||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
.venv/
|
.venv/
|
||||||
|
__pycache__/
|
||||||
.mypy_cache/
|
.mypy_cache/
|
||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
.ruff_cache/
|
.ruff_cache/
|
||||||
|
|||||||
25
apps/web/public/sw.js
Normal file
25
apps/web/public/sw.js
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
self.addEventListener('push', (event) => {
|
||||||
|
const data = event.data?.json() ?? {};
|
||||||
|
event.waitUntil(
|
||||||
|
self.registration.showNotification(data.title ?? 'oO', {
|
||||||
|
body: data.body ?? '',
|
||||||
|
icon: '/icon-192.png',
|
||||||
|
badge: '/icon-192.png',
|
||||||
|
data: { url: data.url ?? '/tip' },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('notificationclick', (event) => {
|
||||||
|
event.notification.close();
|
||||||
|
event.waitUntil(
|
||||||
|
clients.matchAll({ type: 'window', includeUncontrolled: true }).then((list) => {
|
||||||
|
for (const client of list) {
|
||||||
|
if (client.url.includes(self.location.origin) && 'focus' in client) {
|
||||||
|
return client.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return clients.openWindow(event.notification.data?.url ?? '/tip');
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState, useRef, useCallback } from 'react';
|
import { useEffect, useState, useRef, useCallback } from 'react';
|
||||||
import { getRecommendation, sendFeedback } from '@/lib/api';
|
import { getRecommendation, sendFeedback, getVapidPublicKey, subscribePush } from '@/lib/api';
|
||||||
import type { Tip } from '@oo/shared-types';
|
import type { Tip } from '@oo/shared-types';
|
||||||
|
|
||||||
type State = 'loading' | 'tip' | 'empty' | 'actions' | 'done';
|
type State = 'loading' | 'tip' | 'empty' | 'actions' | 'done';
|
||||||
@@ -30,6 +30,7 @@ export default function TipPage() {
|
|||||||
const [visible, setVisible] = useState(false);
|
const [visible, setVisible] = useState(false);
|
||||||
const holdTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const holdTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const [pressed, setPressed] = useState(false);
|
const [pressed, setPressed] = useState(false);
|
||||||
|
const [pushState, setPushState] = useState<'idle' | 'subscribed' | 'denied'>('idle');
|
||||||
|
|
||||||
// Fade in after state change settles
|
// Fade in after state change settles
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -60,6 +61,31 @@ export default function TipPage() {
|
|||||||
|
|
||||||
useEffect(() => { loadTip(); }, [loadTip]);
|
useEffect(() => { loadTip(); }, [loadTip]);
|
||||||
|
|
||||||
|
// Check existing push permission on mount
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof Notification !== 'undefined' && Notification.permission === 'granted') {
|
||||||
|
setPushState('subscribed');
|
||||||
|
} else if (typeof Notification !== 'undefined' && Notification.permission === 'denied') {
|
||||||
|
setPushState('denied');
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const requestPush = useCallback(async () => {
|
||||||
|
if (!('serviceWorker' in navigator) || !('PushManager' in window)) return;
|
||||||
|
const permission = await Notification.requestPermission();
|
||||||
|
if (permission !== 'granted') { setPushState('denied'); return; }
|
||||||
|
try {
|
||||||
|
const reg = await navigator.serviceWorker.register('/sw.js');
|
||||||
|
const vapidKey = await getVapidPublicKey();
|
||||||
|
const sub = await reg.pushManager.subscribe({
|
||||||
|
userVisibleOnly: true,
|
||||||
|
applicationServerKey: vapidKey,
|
||||||
|
});
|
||||||
|
await subscribePush(sub.toJSON());
|
||||||
|
setPushState('subscribed');
|
||||||
|
} catch { setPushState('denied'); }
|
||||||
|
}, []);
|
||||||
|
|
||||||
const react = async (action: 'done' | 'dismiss' | 'snooze') => {
|
const react = async (action: 'done' | 'dismiss' | 'snooze') => {
|
||||||
if (!tip) return;
|
if (!tip) return;
|
||||||
setVisible(false);
|
setVisible(false);
|
||||||
@@ -161,6 +187,24 @@ export default function TipPage() {
|
|||||||
}}>
|
}}>
|
||||||
hold to act
|
hold to act
|
||||||
</p>
|
</p>
|
||||||
|
{pushState === 'idle' && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); requestPush(); }}
|
||||||
|
style={{
|
||||||
|
marginTop: '2.5rem',
|
||||||
|
background: 'transparent',
|
||||||
|
border: 'none',
|
||||||
|
color: 'rgba(255,255,255,0.18)',
|
||||||
|
fontSize: '0.65rem',
|
||||||
|
letterSpacing: '0.12em',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
cursor: 'pointer',
|
||||||
|
padding: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
notify me
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</Fade>
|
</Fade>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -62,3 +62,22 @@ export async function deleteAccount() {
|
|||||||
export async function logout() {
|
export async function logout() {
|
||||||
return apiFetch<{ ok: boolean }>('/auth/logout', { method: 'POST' });
|
return apiFetch<{ ok: boolean }>('/auth/logout', { method: 'POST' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getVapidPublicKey(): Promise<string> {
|
||||||
|
const { key } = await apiFetch<{ key: string }>('/push/vapid-public-key');
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function subscribePush(subscription: PushSubscriptionJSON) {
|
||||||
|
return apiFetch<{ ok: boolean }>('/push/subscribe', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(subscription),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function unsubscribePush(endpoint: string) {
|
||||||
|
return apiFetch<{ ok: boolean }>('/push/subscribe', {
|
||||||
|
method: 'DELETE',
|
||||||
|
body: JSON.stringify({ endpoint }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,32 +1,101 @@
|
|||||||
"""
|
"""
|
||||||
oO ML Serving — Phase 0 stub.
|
oO ML Serving — Phase 1: LinUCB contextual bandit.
|
||||||
|
|
||||||
Returns a placeholder response that matches the interface the real scorer will implement.
|
|
||||||
The recommender service calls this via RemotePolicy (not yet wired in Phase 0).
|
|
||||||
|
|
||||||
Contract:
|
Contract:
|
||||||
POST /score
|
POST /score { user_id, candidates, context } → { tip_id, score, policy }
|
||||||
Body: { user_id: str, candidates: [{ id: str, content: str, source: str, source_id?: str }] }
|
POST /reward { user_id, tip_id, reward, features } → { ok }
|
||||||
Response: { tip_id: str, score: float }
|
GET /health → { ok }
|
||||||
|
|
||||||
|
Features (d=5):
|
||||||
|
hour_sin, hour_cos — cyclical time-of-day encoding
|
||||||
|
is_overdue — 0 or 1
|
||||||
|
task_age_days — days since due date (clipped 0–30, normalised 0–1)
|
||||||
|
priority_norm — Todoist priority 1–4, normalised to 0–1
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
import os
|
||||||
|
import random
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
from fastapi import FastAPI, HTTPException
|
from fastapi import FastAPI, HTTPException
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
import random
|
|
||||||
|
|
||||||
app = FastAPI(title="oO ML Serving", version="0.0.0")
|
app = FastAPI(title="oO ML Serving", version="1.0.0")
|
||||||
|
|
||||||
|
STATE_DIR = Path(os.getenv("STATE_DIR", "/tmp/oo-bandit-state"))
|
||||||
|
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
ALPHA = 1.0 # exploration coefficient
|
||||||
|
D = 5 # feature dimension
|
||||||
|
|
||||||
|
|
||||||
|
# ── Feature helpers ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def build_feature_vector(features: dict) -> np.ndarray:
|
||||||
|
hour = features.get("hour_of_day", 12)
|
||||||
|
hour_sin = math.sin(2 * math.pi * hour / 24)
|
||||||
|
hour_cos = math.cos(2 * math.pi * hour / 24)
|
||||||
|
is_overdue = float(bool(features.get("is_overdue", False)))
|
||||||
|
age = min(float(features.get("task_age_days", 0)), 30.0) / 30.0
|
||||||
|
priority = (float(features.get("priority", 1)) - 1.0) / 3.0
|
||||||
|
return np.array([hour_sin, hour_cos, is_overdue, age, priority], dtype=np.float64)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Per-user bandit state (disjoint LinUCB, global arm) ───────────────────
|
||||||
|
|
||||||
|
def state_path(user_id: str) -> Path:
|
||||||
|
safe = "".join(c if c.isalnum() else "_" for c in user_id)
|
||||||
|
return STATE_DIR / f"{safe}.json"
|
||||||
|
|
||||||
|
|
||||||
|
def load_state(user_id: str) -> tuple[np.ndarray, np.ndarray]:
|
||||||
|
"""Returns (A, b). A is DxD, b is D-vector."""
|
||||||
|
p = state_path(user_id)
|
||||||
|
if p.exists():
|
||||||
|
raw = json.loads(p.read_text())
|
||||||
|
A = np.array(raw["A"], dtype=np.float64)
|
||||||
|
b = np.array(raw["b"], dtype=np.float64)
|
||||||
|
return A, b
|
||||||
|
return np.identity(D, dtype=np.float64), np.zeros(D, dtype=np.float64)
|
||||||
|
|
||||||
|
|
||||||
|
def save_state(user_id: str, A: np.ndarray, b: np.ndarray) -> None:
|
||||||
|
p = state_path(user_id)
|
||||||
|
p.write_text(json.dumps({"A": A.tolist(), "b": b.tolist()}))
|
||||||
|
|
||||||
|
|
||||||
|
# ── API models ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class CandidateFeatures(BaseModel):
|
||||||
|
hour_of_day: int = 12
|
||||||
|
is_overdue: bool = False
|
||||||
|
task_age_days: float = 0.0
|
||||||
|
priority: int = 1
|
||||||
|
|
||||||
|
|
||||||
class Candidate(BaseModel):
|
class Candidate(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
content: str
|
content: str
|
||||||
source: str
|
source: str
|
||||||
source_id: str | None = None
|
source_id: Optional[str] = None
|
||||||
|
features: CandidateFeatures = CandidateFeatures()
|
||||||
|
|
||||||
|
|
||||||
|
class Context(BaseModel):
|
||||||
|
hour_of_day: int = 12
|
||||||
|
day_of_week: int = 0
|
||||||
|
|
||||||
|
|
||||||
class ScoreRequest(BaseModel):
|
class ScoreRequest(BaseModel):
|
||||||
user_id: str
|
user_id: str
|
||||||
candidates: list[Candidate]
|
candidates: list[Candidate]
|
||||||
|
context: Context = Context()
|
||||||
|
|
||||||
|
|
||||||
class ScoreResponse(BaseModel):
|
class ScoreResponse(BaseModel):
|
||||||
@@ -35,15 +104,69 @@ class ScoreResponse(BaseModel):
|
|||||||
policy: str
|
policy: str
|
||||||
|
|
||||||
|
|
||||||
|
class RewardRequest(BaseModel):
|
||||||
|
user_id: str
|
||||||
|
tip_id: str
|
||||||
|
reward: float # +1 done, 0 snooze, -1 dismiss
|
||||||
|
features: CandidateFeatures
|
||||||
|
|
||||||
|
|
||||||
|
class RewardResponse(BaseModel):
|
||||||
|
ok: bool
|
||||||
|
|
||||||
|
|
||||||
|
# ── Endpoints ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
def health():
|
def health():
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/score", response_model=ScoreResponse)
|
@app.post("/score", response_model=ScoreResponse)
|
||||||
def score(req: ScoreRequest):
|
def score(req: ScoreRequest) -> ScoreResponse:
|
||||||
if not req.candidates:
|
if not req.candidates:
|
||||||
raise HTTPException(status_code=422, detail="No candidates")
|
raise HTTPException(status_code=422, detail="No candidates")
|
||||||
# Stub: random uniform scoring — real model slots in here
|
|
||||||
chosen = random.choice(req.candidates)
|
A, b = load_state(req.user_id)
|
||||||
return ScoreResponse(tip_id=chosen.id, score=1.0, policy="stub-random")
|
try:
|
||||||
|
A_inv = np.linalg.inv(A)
|
||||||
|
except np.linalg.LinAlgError:
|
||||||
|
A_inv = np.identity(D, dtype=np.float64)
|
||||||
|
|
||||||
|
theta = A_inv @ b
|
||||||
|
|
||||||
|
best_id = None
|
||||||
|
best_score = -float("inf")
|
||||||
|
|
||||||
|
for candidate in req.candidates:
|
||||||
|
feat_dict = {
|
||||||
|
"hour_of_day": req.context.hour_of_day,
|
||||||
|
"is_overdue": candidate.features.is_overdue,
|
||||||
|
"task_age_days": candidate.features.task_age_days,
|
||||||
|
"priority": candidate.features.priority,
|
||||||
|
}
|
||||||
|
x = build_feature_vector(feat_dict)
|
||||||
|
exploit = float(theta @ x)
|
||||||
|
explore = ALPHA * math.sqrt(float(x @ A_inv @ x))
|
||||||
|
ucb = exploit + explore
|
||||||
|
if ucb > best_score:
|
||||||
|
best_score = ucb
|
||||||
|
best_id = candidate.id
|
||||||
|
|
||||||
|
return ScoreResponse(tip_id=best_id, score=best_score, policy="linucb-v1")
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/reward", response_model=RewardResponse)
|
||||||
|
def reward(req: RewardRequest) -> RewardResponse:
|
||||||
|
A, b = load_state(req.user_id)
|
||||||
|
feat_dict = {
|
||||||
|
"hour_of_day": req.features.hour_of_day,
|
||||||
|
"is_overdue": req.features.is_overdue,
|
||||||
|
"task_age_days": req.features.task_age_days,
|
||||||
|
"priority": req.features.priority,
|
||||||
|
}
|
||||||
|
x = build_feature_vector(feat_dict)
|
||||||
|
A += np.outer(x, x)
|
||||||
|
b += req.reward * x
|
||||||
|
save_state(req.user_id, A, b)
|
||||||
|
return RewardResponse(ok=True)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "uvicorn main:app --reload --port 8000",
|
"dev": ".venv/bin/uvicorn main:app --reload --port 8000",
|
||||||
"start": "uvicorn main:app --port 8000"
|
"start": ".venv/bin/uvicorn main:app --port 8000"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
fastapi==0.115.6
|
fastapi==0.115.6
|
||||||
uvicorn[standard]==0.32.1
|
uvicorn[standard]==0.32.1
|
||||||
pydantic==2.10.4
|
pydantic==2.10.4
|
||||||
|
numpy>=1.26.0
|
||||||
|
|||||||
100
pnpm-lock.yaml
generated
100
pnpm-lock.yaml
generated
@@ -89,6 +89,9 @@ importers:
|
|||||||
openid-client:
|
openid-client:
|
||||||
specifier: ^6.3.4
|
specifier: ^6.3.4
|
||||||
version: 6.8.3
|
version: 6.8.3
|
||||||
|
web-push:
|
||||||
|
specifier: ^3.6.7
|
||||||
|
version: 3.6.7
|
||||||
zod:
|
zod:
|
||||||
specifier: ^3.24.1
|
specifier: ^3.24.1
|
||||||
version: 3.25.76
|
version: 3.25.76
|
||||||
@@ -108,6 +111,9 @@ importers:
|
|||||||
'@types/express-session':
|
'@types/express-session':
|
||||||
specifier: ^1.18.1
|
specifier: ^1.18.1
|
||||||
version: 1.18.2
|
version: 1.18.2
|
||||||
|
'@types/web-push':
|
||||||
|
specifier: ^3.6.4
|
||||||
|
version: 3.6.4
|
||||||
drizzle-kit:
|
drizzle-kit:
|
||||||
specifier: ^0.30.4
|
specifier: ^0.30.4
|
||||||
version: 0.30.6
|
version: 0.30.6
|
||||||
@@ -856,13 +862,23 @@ packages:
|
|||||||
'@types/serve-static@2.2.0':
|
'@types/serve-static@2.2.0':
|
||||||
resolution: {integrity: sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==}
|
resolution: {integrity: sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==}
|
||||||
|
|
||||||
|
'@types/web-push@3.6.4':
|
||||||
|
resolution: {integrity: sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ==}
|
||||||
|
|
||||||
accepts@1.3.8:
|
accepts@1.3.8:
|
||||||
resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==}
|
resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==}
|
||||||
engines: {node: '>= 0.6'}
|
engines: {node: '>= 0.6'}
|
||||||
|
|
||||||
|
agent-base@7.1.4:
|
||||||
|
resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==}
|
||||||
|
engines: {node: '>= 14'}
|
||||||
|
|
||||||
array-flatten@1.1.1:
|
array-flatten@1.1.1:
|
||||||
resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==}
|
resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==}
|
||||||
|
|
||||||
|
asn1.js@5.4.1:
|
||||||
|
resolution: {integrity: sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==}
|
||||||
|
|
||||||
base64-js@1.5.1:
|
base64-js@1.5.1:
|
||||||
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
|
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
|
||||||
|
|
||||||
@@ -875,10 +891,16 @@ packages:
|
|||||||
bl@4.1.0:
|
bl@4.1.0:
|
||||||
resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==}
|
resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==}
|
||||||
|
|
||||||
|
bn.js@4.12.3:
|
||||||
|
resolution: {integrity: sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==}
|
||||||
|
|
||||||
body-parser@1.20.4:
|
body-parser@1.20.4:
|
||||||
resolution: {integrity: sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==}
|
resolution: {integrity: sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==}
|
||||||
engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16}
|
engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16}
|
||||||
|
|
||||||
|
buffer-equal-constant-time@1.0.1:
|
||||||
|
resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==}
|
||||||
|
|
||||||
buffer-from@1.1.2:
|
buffer-from@1.1.2:
|
||||||
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
|
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
|
||||||
|
|
||||||
@@ -1080,6 +1102,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
|
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
ecdsa-sig-formatter@1.0.11:
|
||||||
|
resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==}
|
||||||
|
|
||||||
ee-first@1.1.1:
|
ee-first@1.1.1:
|
||||||
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
|
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
|
||||||
|
|
||||||
@@ -1214,6 +1239,14 @@ packages:
|
|||||||
resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
|
resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
|
||||||
engines: {node: '>= 0.8'}
|
engines: {node: '>= 0.8'}
|
||||||
|
|
||||||
|
http_ece@1.2.0:
|
||||||
|
resolution: {integrity: sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==}
|
||||||
|
engines: {node: '>=16'}
|
||||||
|
|
||||||
|
https-proxy-agent@7.0.6:
|
||||||
|
resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
|
||||||
|
engines: {node: '>= 14'}
|
||||||
|
|
||||||
iconv-lite@0.4.24:
|
iconv-lite@0.4.24:
|
||||||
resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==}
|
resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@@ -1238,6 +1271,12 @@ packages:
|
|||||||
jose@6.2.2:
|
jose@6.2.2:
|
||||||
resolution: {integrity: sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==}
|
resolution: {integrity: sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==}
|
||||||
|
|
||||||
|
jwa@2.0.1:
|
||||||
|
resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==}
|
||||||
|
|
||||||
|
jws@4.0.1:
|
||||||
|
resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==}
|
||||||
|
|
||||||
math-intrinsics@1.1.0:
|
math-intrinsics@1.1.0:
|
||||||
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
|
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -1270,6 +1309,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==}
|
resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
|
minimalistic-assert@1.0.1:
|
||||||
|
resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==}
|
||||||
|
|
||||||
minimist@1.2.8:
|
minimist@1.2.8:
|
||||||
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
|
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
|
||||||
|
|
||||||
@@ -1568,6 +1610,11 @@ packages:
|
|||||||
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
|
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
|
||||||
engines: {node: '>= 0.8'}
|
engines: {node: '>= 0.8'}
|
||||||
|
|
||||||
|
web-push@3.6.7:
|
||||||
|
resolution: {integrity: sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==}
|
||||||
|
engines: {node: '>= 16'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
web-streams-polyfill@3.3.3:
|
web-streams-polyfill@3.3.3:
|
||||||
resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==}
|
resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==}
|
||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
@@ -2027,13 +2074,26 @@ snapshots:
|
|||||||
'@types/http-errors': 2.0.5
|
'@types/http-errors': 2.0.5
|
||||||
'@types/node': 22.19.17
|
'@types/node': 22.19.17
|
||||||
|
|
||||||
|
'@types/web-push@3.6.4':
|
||||||
|
dependencies:
|
||||||
|
'@types/node': 22.19.17
|
||||||
|
|
||||||
accepts@1.3.8:
|
accepts@1.3.8:
|
||||||
dependencies:
|
dependencies:
|
||||||
mime-types: 2.1.35
|
mime-types: 2.1.35
|
||||||
negotiator: 0.6.3
|
negotiator: 0.6.3
|
||||||
|
|
||||||
|
agent-base@7.1.4: {}
|
||||||
|
|
||||||
array-flatten@1.1.1: {}
|
array-flatten@1.1.1: {}
|
||||||
|
|
||||||
|
asn1.js@5.4.1:
|
||||||
|
dependencies:
|
||||||
|
bn.js: 4.12.3
|
||||||
|
inherits: 2.0.4
|
||||||
|
minimalistic-assert: 1.0.1
|
||||||
|
safer-buffer: 2.1.2
|
||||||
|
|
||||||
base64-js@1.5.1: {}
|
base64-js@1.5.1: {}
|
||||||
|
|
||||||
better-sqlite3@11.10.0:
|
better-sqlite3@11.10.0:
|
||||||
@@ -2051,6 +2111,8 @@ snapshots:
|
|||||||
inherits: 2.0.4
|
inherits: 2.0.4
|
||||||
readable-stream: 3.6.2
|
readable-stream: 3.6.2
|
||||||
|
|
||||||
|
bn.js@4.12.3: {}
|
||||||
|
|
||||||
body-parser@1.20.4:
|
body-parser@1.20.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
bytes: 3.1.2
|
bytes: 3.1.2
|
||||||
@@ -2068,6 +2130,8 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
|
buffer-equal-constant-time@1.0.1: {}
|
||||||
|
|
||||||
buffer-from@1.1.2: {}
|
buffer-from@1.1.2: {}
|
||||||
|
|
||||||
buffer@5.7.1:
|
buffer@5.7.1:
|
||||||
@@ -2164,6 +2228,10 @@ snapshots:
|
|||||||
es-errors: 1.3.0
|
es-errors: 1.3.0
|
||||||
gopd: 1.2.0
|
gopd: 1.2.0
|
||||||
|
|
||||||
|
ecdsa-sig-formatter@1.0.11:
|
||||||
|
dependencies:
|
||||||
|
safe-buffer: 5.2.1
|
||||||
|
|
||||||
ee-first@1.1.1: {}
|
ee-first@1.1.1: {}
|
||||||
|
|
||||||
encodeurl@2.0.0: {}
|
encodeurl@2.0.0: {}
|
||||||
@@ -2409,6 +2477,15 @@ snapshots:
|
|||||||
statuses: 2.0.2
|
statuses: 2.0.2
|
||||||
toidentifier: 1.0.1
|
toidentifier: 1.0.1
|
||||||
|
|
||||||
|
http_ece@1.2.0: {}
|
||||||
|
|
||||||
|
https-proxy-agent@7.0.6:
|
||||||
|
dependencies:
|
||||||
|
agent-base: 7.1.4
|
||||||
|
debug: 4.4.3
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- supports-color
|
||||||
|
|
||||||
iconv-lite@0.4.24:
|
iconv-lite@0.4.24:
|
||||||
dependencies:
|
dependencies:
|
||||||
safer-buffer: 2.1.2
|
safer-buffer: 2.1.2
|
||||||
@@ -2425,6 +2502,17 @@ snapshots:
|
|||||||
|
|
||||||
jose@6.2.2: {}
|
jose@6.2.2: {}
|
||||||
|
|
||||||
|
jwa@2.0.1:
|
||||||
|
dependencies:
|
||||||
|
buffer-equal-constant-time: 1.0.1
|
||||||
|
ecdsa-sig-formatter: 1.0.11
|
||||||
|
safe-buffer: 5.2.1
|
||||||
|
|
||||||
|
jws@4.0.1:
|
||||||
|
dependencies:
|
||||||
|
jwa: 2.0.1
|
||||||
|
safe-buffer: 5.2.1
|
||||||
|
|
||||||
math-intrinsics@1.1.0: {}
|
math-intrinsics@1.1.0: {}
|
||||||
|
|
||||||
media-typer@0.3.0: {}
|
media-typer@0.3.0: {}
|
||||||
@@ -2443,6 +2531,8 @@ snapshots:
|
|||||||
|
|
||||||
mimic-response@3.1.0: {}
|
mimic-response@3.1.0: {}
|
||||||
|
|
||||||
|
minimalistic-assert@1.0.1: {}
|
||||||
|
|
||||||
minimist@1.2.8: {}
|
minimist@1.2.8: {}
|
||||||
|
|
||||||
mkdirp-classic@0.5.3: {}
|
mkdirp-classic@0.5.3: {}
|
||||||
@@ -2778,6 +2868,16 @@ snapshots:
|
|||||||
|
|
||||||
vary@1.1.2: {}
|
vary@1.1.2: {}
|
||||||
|
|
||||||
|
web-push@3.6.7:
|
||||||
|
dependencies:
|
||||||
|
asn1.js: 5.4.1
|
||||||
|
http_ece: 1.2.0
|
||||||
|
https-proxy-agent: 7.0.6
|
||||||
|
jws: 4.0.1
|
||||||
|
minimist: 1.2.8
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- supports-color
|
||||||
|
|
||||||
web-streams-polyfill@3.3.3: {}
|
web-streams-polyfill@3.3.3: {}
|
||||||
|
|
||||||
which@4.0.0:
|
which@4.0.0:
|
||||||
|
|||||||
@@ -13,26 +13,28 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@oo/shared-types": "workspace:*",
|
"@oo/shared-types": "workspace:*",
|
||||||
|
"better-sqlite3": "^11.8.1",
|
||||||
|
"cookie-parser": "^1.4.7",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"dotenv": "^16.4.7",
|
||||||
|
"drizzle-orm": "^0.38.3",
|
||||||
"express": "^4.21.2",
|
"express": "^4.21.2",
|
||||||
"express-session": "^1.18.1",
|
"express-session": "^1.18.1",
|
||||||
"better-sqlite3": "^11.8.1",
|
|
||||||
"drizzle-orm": "^0.38.3",
|
|
||||||
"openid-client": "^6.3.4",
|
|
||||||
"node-fetch": "^3.3.2",
|
|
||||||
"dotenv": "^16.4.7",
|
|
||||||
"zod": "^3.24.1",
|
|
||||||
"nanoid": "^5.1.0",
|
"nanoid": "^5.1.0",
|
||||||
"cookie-parser": "^1.4.7",
|
"node-fetch": "^3.3.2",
|
||||||
"cors": "^2.8.5"
|
"openid-client": "^6.3.4",
|
||||||
|
"web-push": "^3.6.7",
|
||||||
|
"zod": "^3.24.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/express": "^5.0.0",
|
|
||||||
"@types/express-session": "^1.18.1",
|
|
||||||
"@types/better-sqlite3": "^7.6.12",
|
"@types/better-sqlite3": "^7.6.12",
|
||||||
"@types/cookie-parser": "^1.4.8",
|
"@types/cookie-parser": "^1.4.8",
|
||||||
"@types/cors": "^2.8.17",
|
"@types/cors": "^2.8.17",
|
||||||
|
"@types/express": "^5.0.0",
|
||||||
|
"@types/express-session": "^1.18.1",
|
||||||
|
"@types/web-push": "^3.6.4",
|
||||||
|
"drizzle-kit": "^0.30.4",
|
||||||
"tsx": "^4.19.2",
|
"tsx": "^4.19.2",
|
||||||
"typescript": "^5.7.3",
|
"typescript": "^5.7.3"
|
||||||
"drizzle-kit": "^0.30.4"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,4 +32,8 @@ export const config = {
|
|||||||
WEB_BASE_URL: optional('WEB_BASE_URL', 'http://localhost:3000'),
|
WEB_BASE_URL: optional('WEB_BASE_URL', 'http://localhost:3000'),
|
||||||
|
|
||||||
ML_SERVING_URL: optional('ML_SERVING_URL', 'http://localhost:8000'),
|
ML_SERVING_URL: optional('ML_SERVING_URL', 'http://localhost:8000'),
|
||||||
|
|
||||||
|
VAPID_PUBLIC_KEY: optional('VAPID_PUBLIC_KEY', ''),
|
||||||
|
VAPID_PRIVATE_KEY: optional('VAPID_PRIVATE_KEY', ''),
|
||||||
|
VAPID_SUBJECT: optional('VAPID_SUBJECT', 'mailto:admin@localhost'),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -39,6 +39,15 @@ export const tipViews = sqliteTable('tip_views', {
|
|||||||
servedAt: text('served_at').notNull(),
|
servedAt: text('served_at').notNull(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const pushSubscriptions = sqliteTable('push_subscriptions', {
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
userId: text('user_id').notNull().references(() => users.id),
|
||||||
|
endpoint: text('endpoint').notNull().unique(),
|
||||||
|
p256dh: text('p256dh').notNull(),
|
||||||
|
auth: text('auth').notNull(),
|
||||||
|
createdAt: text('created_at').notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
export const sessions = sqliteTable('sessions', {
|
export const sessions = sqliteTable('sessions', {
|
||||||
id: text('id').primaryKey(),
|
id: text('id').primaryKey(),
|
||||||
userId: text('user_id').notNull().references(() => users.id),
|
userId: text('user_id').notNull().references(() => users.id),
|
||||||
|
|||||||
52
services/api/src/events/bus.ts
Normal file
52
services/api/src/events/bus.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
/**
|
||||||
|
* EventBus — in-process today, NATS JetStream tomorrow.
|
||||||
|
*
|
||||||
|
* To swap to NATS: replace the EventEmitter body with a NATS JetStream
|
||||||
|
* publish call. Subjects and payload shapes are the durable contract.
|
||||||
|
*
|
||||||
|
* Subjects follow the pattern: <domain>.<entity>.<verb>
|
||||||
|
* signals.tip.served — a tip was returned to the client
|
||||||
|
* signals.tip.feedback — user reacted (done / dismiss / snooze)
|
||||||
|
* signals.task.synced — Todoist task list refreshed for a user
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { EventEmitter } from 'events';
|
||||||
|
|
||||||
|
export type TipServedEvent = {
|
||||||
|
userId: string;
|
||||||
|
tipId: string;
|
||||||
|
policy: string;
|
||||||
|
servedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TipFeedbackEvent = {
|
||||||
|
userId: string;
|
||||||
|
tipId: string;
|
||||||
|
action: 'done' | 'dismiss' | 'snooze';
|
||||||
|
reward: number;
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TaskSyncedEvent = {
|
||||||
|
userId: string;
|
||||||
|
count: number;
|
||||||
|
syncedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type EventMap = {
|
||||||
|
'signals.tip.served': TipServedEvent;
|
||||||
|
'signals.tip.feedback': TipFeedbackEvent;
|
||||||
|
'signals.task.synced': TaskSyncedEvent;
|
||||||
|
};
|
||||||
|
|
||||||
|
class Bus extends EventEmitter {
|
||||||
|
publish<K extends keyof EventMap>(subject: K, payload: EventMap[K]): void {
|
||||||
|
this.emit(subject, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribe<K extends keyof EventMap>(subject: K, handler: (payload: EventMap[K]) => void): void {
|
||||||
|
this.on(subject, handler);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const bus = new Bus();
|
||||||
@@ -9,6 +9,7 @@ import { authRouter } from './routes/auth.js';
|
|||||||
import { integrationsRouter } from './routes/integrations.js';
|
import { integrationsRouter } from './routes/integrations.js';
|
||||||
import { recommenderRouter } from './routes/recommender.js';
|
import { recommenderRouter } from './routes/recommender.js';
|
||||||
import { userRouter } from './routes/user.js';
|
import { userRouter } from './routes/user.js';
|
||||||
|
import { pushRouter } from './routes/push.js';
|
||||||
import { mkdir } from 'fs/promises';
|
import { mkdir } from 'fs/promises';
|
||||||
import { dirname } from 'path';
|
import { dirname } from 'path';
|
||||||
|
|
||||||
@@ -33,6 +34,7 @@ app.use('/api/auth', authRouter);
|
|||||||
app.use('/api/integrations', integrationsRouter);
|
app.use('/api/integrations', integrationsRouter);
|
||||||
app.use('/api', recommenderRouter);
|
app.use('/api', recommenderRouter);
|
||||||
app.use('/api/user', userRouter);
|
app.use('/api/user', userRouter);
|
||||||
|
app.use('/api/push', pushRouter);
|
||||||
|
|
||||||
app.listen(config.PORT, () => {
|
app.listen(config.PORT, () => {
|
||||||
console.log(`oO API listening on http://localhost:${config.PORT}`);
|
console.log(`oO API listening on http://localhost:${config.PORT}`);
|
||||||
|
|||||||
98
services/api/src/routes/push.ts
Normal file
98
services/api/src/routes/push.ts
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import { type Router as ExpressRouter, Router, Response } from 'express';
|
||||||
|
import webpush from 'web-push';
|
||||||
|
import { nanoid } from 'nanoid';
|
||||||
|
import { db } from '../db/index.js';
|
||||||
|
import { pushSubscriptions } from '../db/schema.js';
|
||||||
|
import { eq, and } from 'drizzle-orm';
|
||||||
|
import { requireAuth, AuthenticatedRequest } from '../middleware/session.js';
|
||||||
|
import { config } from '../config.js';
|
||||||
|
|
||||||
|
const router: ExpressRouter = Router();
|
||||||
|
|
||||||
|
if (config.VAPID_PUBLIC_KEY && config.VAPID_PRIVATE_KEY) {
|
||||||
|
webpush.setVapidDetails(
|
||||||
|
config.VAPID_SUBJECT,
|
||||||
|
config.VAPID_PUBLIC_KEY,
|
||||||
|
config.VAPID_PRIVATE_KEY,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /api/push/vapid-public-key — client needs this to subscribe */
|
||||||
|
router.get('/vapid-public-key', (_req, res: Response) => {
|
||||||
|
res.json({ key: config.VAPID_PUBLIC_KEY });
|
||||||
|
});
|
||||||
|
|
||||||
|
/** POST /api/push/subscribe — save or refresh a push subscription */
|
||||||
|
router.post('/subscribe', requireAuth, async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
const { endpoint, keys } = req.body as {
|
||||||
|
endpoint: string;
|
||||||
|
keys: { p256dh: string; auth: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!endpoint || !keys?.p256dh || !keys?.auth) {
|
||||||
|
res.status(400).json({ error: 'Invalid subscription' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upsert by endpoint
|
||||||
|
const existing = await db
|
||||||
|
.select()
|
||||||
|
.from(pushSubscriptions)
|
||||||
|
.where(eq(pushSubscriptions.endpoint, endpoint))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (existing.length) {
|
||||||
|
await db
|
||||||
|
.update(pushSubscriptions)
|
||||||
|
.set({ p256dh: keys.p256dh, auth: keys.auth })
|
||||||
|
.where(eq(pushSubscriptions.endpoint, endpoint));
|
||||||
|
} else {
|
||||||
|
await db.insert(pushSubscriptions).values({
|
||||||
|
id: nanoid(),
|
||||||
|
userId: req.userId!,
|
||||||
|
endpoint,
|
||||||
|
p256dh: keys.p256dh,
|
||||||
|
auth: keys.auth,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
/** DELETE /api/push/subscribe — unsubscribe */
|
||||||
|
router.delete('/subscribe', requireAuth, async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
const { endpoint } = req.body as { endpoint: string };
|
||||||
|
if (endpoint) {
|
||||||
|
await db
|
||||||
|
.delete(pushSubscriptions)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(pushSubscriptions.userId, req.userId!),
|
||||||
|
eq(pushSubscriptions.endpoint, endpoint),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Send a push notification to a user — called internally */
|
||||||
|
export async function sendPushToUser(userId: string, payload: { title: string; body: string; url?: string }) {
|
||||||
|
if (!config.VAPID_PUBLIC_KEY) return;
|
||||||
|
|
||||||
|
const subs = await db
|
||||||
|
.select()
|
||||||
|
.from(pushSubscriptions)
|
||||||
|
.where(eq(pushSubscriptions.userId, userId));
|
||||||
|
|
||||||
|
await Promise.allSettled(
|
||||||
|
subs.map((sub) =>
|
||||||
|
webpush.sendNotification(
|
||||||
|
{ endpoint: sub.endpoint, keys: { p256dh: sub.p256dh, auth: sub.auth } },
|
||||||
|
JSON.stringify(payload),
|
||||||
|
).catch(() => {}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { router as pushRouter };
|
||||||
@@ -4,44 +4,109 @@ import { db } from '../db/index.js';
|
|||||||
import { integrationTokens, tipFeedback, tipViews } from '../db/schema.js';
|
import { integrationTokens, tipFeedback, tipViews } from '../db/schema.js';
|
||||||
import { eq, and } from 'drizzle-orm';
|
import { eq, and } from 'drizzle-orm';
|
||||||
import { requireAuth, AuthenticatedRequest } from '../middleware/session.js';
|
import { requireAuth, AuthenticatedRequest } from '../middleware/session.js';
|
||||||
|
import { config } from '../config.js';
|
||||||
|
import { bus } from '../events/bus.js';
|
||||||
import type { Tip } from '@oo/shared-types';
|
import type { Tip } from '@oo/shared-types';
|
||||||
|
|
||||||
const router: ExpressRouter = Router();
|
const router: ExpressRouter = Router();
|
||||||
|
|
||||||
const CACHE_TTL_MS = 30_000; // 30 seconds
|
const CACHE_TTL_MS = 30_000;
|
||||||
const taskCache = new Map<string, { tips: Tip[]; fetchedAt: number }>();
|
|
||||||
|
|
||||||
/** Fetch active Todoist tasks, with a 30s in-memory cache per user */
|
interface TaskFeatures {
|
||||||
async function fetchTodoistTasks(userId: string, accessToken: string): Promise<Tip[]> {
|
is_overdue: boolean;
|
||||||
|
task_age_days: number;
|
||||||
|
priority: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CachedTask extends Tip {
|
||||||
|
features: TaskFeatures;
|
||||||
|
}
|
||||||
|
|
||||||
|
const taskCache = new Map<string, { tasks: CachedTask[]; fetchedAt: number }>();
|
||||||
|
|
||||||
|
/** Parse a Todoist due date string into age in days (relative to now) */
|
||||||
|
function dueAgeDays(due: { date?: string; datetime?: string } | null | undefined): number {
|
||||||
|
if (!due) return 0;
|
||||||
|
const dateStr = due.datetime ?? due.date;
|
||||||
|
if (!dateStr) return 0;
|
||||||
|
const dueMs = new Date(dateStr).getTime();
|
||||||
|
return Math.max(0, (Date.now() - dueMs) / (1000 * 60 * 60 * 24));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchTodoistTasks(userId: string, accessToken: string): Promise<CachedTask[]> {
|
||||||
const cached = taskCache.get(userId);
|
const cached = taskCache.get(userId);
|
||||||
if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) {
|
if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) return cached.tasks;
|
||||||
return cached.tips;
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await fetch('https://api.todoist.com/api/v1/tasks?filter=today%7Coverdue', {
|
const res = await fetch('https://api.todoist.com/api/v1/tasks?filter=today%7Coverdue', {
|
||||||
headers: { Authorization: `Bearer ${accessToken}` },
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) return cached?.tips ?? [];
|
if (!res.ok) return cached?.tasks ?? [];
|
||||||
|
|
||||||
const body = (await res.json()) as { results: Array<{ id: string; content: string }> };
|
const body = (await res.json()) as {
|
||||||
const tips: Tip[] = (body.results ?? []).map((t) => ({
|
results: Array<{
|
||||||
|
id: string;
|
||||||
|
content: string;
|
||||||
|
priority: number;
|
||||||
|
due: { date?: string; datetime?: string; is_recurring?: boolean } | null;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const tasks: CachedTask[] = (body.results ?? []).map((t) => {
|
||||||
|
const ageDays = dueAgeDays(t.due);
|
||||||
|
const isOverdue = ageDays > 0;
|
||||||
|
return {
|
||||||
id: `todoist:${t.id}`,
|
id: `todoist:${t.id}`,
|
||||||
content: t.content,
|
content: t.content,
|
||||||
source: 'todoist' as const,
|
source: 'todoist' as const,
|
||||||
sourceId: t.id,
|
sourceId: t.id,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: now.toISOString(),
|
||||||
}));
|
features: {
|
||||||
|
is_overdue: isOverdue,
|
||||||
|
task_age_days: ageDays,
|
||||||
|
priority: t.priority ?? 1,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
taskCache.set(userId, { tips, fetchedAt: Date.now() });
|
taskCache.set(userId, { tasks, fetchedAt: Date.now() });
|
||||||
return tips;
|
return tasks;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Call ml/serving for scored selection; returns tip_id or null on failure */
|
||||||
* RandomPolicy — picks one task at random from the candidate set.
|
async function remotePolicy(userId: string, tasks: CachedTask[]): Promise<string | null> {
|
||||||
* Contract: same interface the ML scorer will implement.
|
const hour = new Date().getHours();
|
||||||
*/
|
const dayOfWeek = new Date().getDay();
|
||||||
function randomPolicy(candidates: Tip[]): Tip | null {
|
|
||||||
|
const body = {
|
||||||
|
user_id: userId,
|
||||||
|
candidates: tasks.map((t) => ({
|
||||||
|
id: t.id,
|
||||||
|
content: t.content,
|
||||||
|
source: t.source,
|
||||||
|
source_id: t.sourceId ?? null,
|
||||||
|
features: t.features,
|
||||||
|
})),
|
||||||
|
context: { hour_of_day: hour, day_of_week: dayOfWeek },
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${config.ML_SERVING_URL}/score`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
signal: AbortSignal.timeout(3000),
|
||||||
|
});
|
||||||
|
if (!res.ok) return null;
|
||||||
|
const { tip_id } = (await res.json()) as { tip_id: string };
|
||||||
|
return tip_id;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function randomPolicy(candidates: CachedTask[]): CachedTask | null {
|
||||||
if (!candidates.length) return null;
|
if (!candidates.length) return null;
|
||||||
return candidates[Math.floor(Math.random() * candidates.length)];
|
return candidates[Math.floor(Math.random() * candidates.length)];
|
||||||
}
|
}
|
||||||
@@ -51,12 +116,7 @@ router.post('/recommend', requireAuth, async (req: AuthenticatedRequest, res: Re
|
|||||||
const [token] = await db
|
const [token] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(integrationTokens)
|
.from(integrationTokens)
|
||||||
.where(
|
.where(and(eq(integrationTokens.userId, req.userId!), eq(integrationTokens.provider, 'todoist')))
|
||||||
and(
|
|
||||||
eq(integrationTokens.userId, req.userId!),
|
|
||||||
eq(integrationTokens.provider, 'todoist'),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
@@ -64,20 +124,31 @@ router.post('/recommend', requireAuth, async (req: AuthenticatedRequest, res: Re
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const candidates = await fetchTodoistTasks(req.userId!, token.accessToken);
|
const tasks = await fetchTodoistTasks(req.userId!, token.accessToken);
|
||||||
const tip = randomPolicy(candidates);
|
if (!tasks.length) {
|
||||||
|
res.status(204).end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemotePolicy with RandomPolicy fallback
|
||||||
|
const scoredId = await remotePolicy(req.userId!, tasks);
|
||||||
|
const tip = scoredId
|
||||||
|
? (tasks.find((t) => t.id === scoredId) ?? randomPolicy(tasks))
|
||||||
|
: randomPolicy(tasks);
|
||||||
|
|
||||||
if (!tip) {
|
if (!tip) {
|
||||||
res.status(204).end();
|
res.status(204).end();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Record metric: tip served
|
const servedAt = new Date().toISOString();
|
||||||
await db.insert(tipViews).values({
|
await db.insert(tipViews).values({ id: nanoid(), userId: req.userId!, tipId: tip.id, servedAt });
|
||||||
id: nanoid(),
|
|
||||||
|
bus.publish('signals.tip.served', {
|
||||||
userId: req.userId!,
|
userId: req.userId!,
|
||||||
tipId: tip.id,
|
tipId: tip.id,
|
||||||
servedAt: new Date().toISOString(),
|
policy: scoredId ? 'linucb-v1' : 'random',
|
||||||
|
servedAt,
|
||||||
});
|
});
|
||||||
|
|
||||||
res.json({ tip });
|
res.json({ tip });
|
||||||
@@ -102,27 +173,44 @@ router.post('/tip/:id/feedback', requireAuth, async (req: AuthenticatedRequest,
|
|||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Invalidate cache so next recommend fetches fresh tasks
|
// Capture task features before clearing cache
|
||||||
|
const reward = action === 'done' ? 1.0 : action === 'dismiss' ? -1.0 : 0.0;
|
||||||
|
const task = taskCache.get(req.userId!)?.tasks.find((t) => t.id === tipId);
|
||||||
taskCache.delete(req.userId!);
|
taskCache.delete(req.userId!);
|
||||||
|
|
||||||
// If done, mark complete in Todoist
|
bus.publish('signals.tip.feedback', {
|
||||||
|
userId: req.userId!,
|
||||||
|
tipId,
|
||||||
|
action: action as 'done' | 'dismiss' | 'snooze',
|
||||||
|
reward,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
if (task) {
|
||||||
|
fetch(`${config.ML_SERVING_URL}/reward`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
user_id: req.userId!,
|
||||||
|
tip_id: tipId,
|
||||||
|
reward,
|
||||||
|
features: task.features,
|
||||||
|
}),
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark complete in Todoist if done
|
||||||
if (action === 'done' && tipId.startsWith('todoist:')) {
|
if (action === 'done' && tipId.startsWith('todoist:')) {
|
||||||
const todoistId = tipId.slice(8);
|
const todoistId = tipId.slice(8);
|
||||||
const [token] = await db
|
const [tok] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(integrationTokens)
|
.from(integrationTokens)
|
||||||
.where(
|
.where(and(eq(integrationTokens.userId, req.userId!), eq(integrationTokens.provider, 'todoist')))
|
||||||
and(
|
|
||||||
eq(integrationTokens.userId, req.userId!),
|
|
||||||
eq(integrationTokens.provider, 'todoist'),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (token) {
|
if (tok) {
|
||||||
await fetch(`https://api.todoist.com/api/v1/tasks/${todoistId}/close`, {
|
await fetch(`https://api.todoist.com/api/v1/tasks/${todoistId}/close`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { Authorization: `Bearer ${token.accessToken}` },
|
headers: { Authorization: `Bearer ${tok.accessToken}` },
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user