Skip to content

Commit d43151c

Browse files
committed
Resolve PR #225 conflicts (combine both)
2 parents 0e86b72 + 9ce326e commit d43151c

68 files changed

Lines changed: 8208 additions & 1605 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/typecheck.yml

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
name: TypeScript Check
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
branches: [main]
8+
9+
jobs:
10+
typecheck-backend:
11+
name: TypeScript Check (Backend)
12+
runs-on: ubuntu-latest
13+
defaults:
14+
run:
15+
working-directory: backend
16+
steps:
17+
- uses: actions/checkout@v4
18+
- uses: pnpm/action-setup@v4
19+
with:
20+
version: 9
21+
- uses: actions/setup-node@v4
22+
with:
23+
node-version: '20'
24+
cache: 'pnpm'
25+
cache-dependency-path: backend/pnpm-lock.yaml
26+
- run: pnpm install
27+
- run: npx tsc --noEmit
28+
29+
typecheck-client:
30+
name: TypeScript Check (Client)
31+
runs-on: ubuntu-latest
32+
defaults:
33+
run:
34+
working-directory: client
35+
steps:
36+
- uses: actions/checkout@v4
37+
- uses: actions/setup-node@v4
38+
with:
39+
node-version: '20'
40+
cache: 'npm'
41+
cache-dependency-path: client/package-lock.json
42+
- run: npm ci
43+
- run: npx tsc --noEmit

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
[![TypeScript Check](https://github.com/Calebux/SYNCRO/actions/workflows/typecheck.yml/badge.svg)](https://github.com/Calebux/SYNCRO/actions/workflows/typecheck.yml)
12
# SYNCRO
23

34
![Tests](https://github.com/Calebux/SYNCRO/actions/workflows/test.yml/badge.svg)

backend/.env.example

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ VAPID_SUBJECT=mailto:noreply@synchro.app
4343
# Rate Limiting Configuration (optional)
4444
# Redis URL for persistent rate limiting across server instances
4545
RATE_LIMIT_REDIS_URL=redis://localhost:6379
46+
# Redis connection URL used by the renewal rate limiter and other Redis-backed services
47+
REDIS_URL=redis://localhost:6379
4648

4749
# Enable/disable Redis for rate limiting (defaults to true if URL is provided)
4850
RATE_LIMIT_REDIS_ENABLED=true
@@ -60,6 +62,12 @@ RATE_LIMIT_ADMIN_MAX=100
6062
RATE_LIMIT_ADMIN_WINDOW_HOURS=1
6163

6264
# External API Keys (optional)
65+
ANTHROPIC_API_KEY=your_anthropic_api_key_for_classification
66+
# Sentry
67+
SENTRY_DSN=your_sentry_dsn_here
68+
SENTRY_AUTH_TOKEN=your_sentry_auth_token_here
69+
SENTRY_ORG=your_sentry_org_here
70+
SENTRY_PROJECT=your_sentry_project_here
6371
ANTHROPIC_API_KEY=your_anthropic_api_key_for_classification
6472
# Risk calculation concurrency (number of simultaneous risk calculations per page)
6573
RISK_CALC_CONCURRENCY=10
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
-- Create subscription_price_history table
2+
CREATE TABLE IF NOT EXISTS subscription_price_history (
3+
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
4+
subscription_id UUID NOT NULL REFERENCES subscriptions(id) ON DELETE CASCADE,
5+
old_price DECIMAL(10, 2) NOT NULL,
6+
new_price DECIMAL(10, 2) NOT NULL,
7+
changed_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now()) NOT NULL,
8+
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE
9+
);
10+
11+
-- Enable RLS
12+
ALTER TABLE subscription_price_history ENABLE ROW LEVEL SECURITY;
13+
14+
-- RLS Policies
15+
CREATE POLICY "Users can view their own price history"
16+
ON subscription_price_history FOR SELECT
17+
USING (auth.uid() = user_id);
18+
19+
-- Function to handle price changes
20+
CREATE OR REPLACE FUNCTION handle_subscription_price_change()
21+
RETURNS TRIGGER AS $$
22+
BEGIN
23+
IF (OLD.price IS DISTINCT FROM NEW.price) THEN
24+
INSERT INTO subscription_price_history (subscription_id, old_price, new_price, user_id)
25+
VALUES (NEW.id, OLD.price, NEW.price, NEW.user_id);
26+
END IF;
27+
RETURN NEW;
28+
END;
29+
$$ LANGUAGE plpgsql SECURITY DEFINER;
30+
31+
-- Trigger for price changes
32+
DROP TRIGGER IF EXISTS on_subscription_price_change ON subscriptions;
33+
CREATE TRIGGER on_subscription_price_change
34+
AFTER UPDATE ON subscriptions
35+
FOR EACH ROW
36+
EXECUTE FUNCTION handle_subscription_price_change();

backend/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@
1919
"db:new": "supabase migration new"
2020
},
2121
"dependencies": {
22+
"@sentry/node": "^10.46.0",
23+
"@sentry/profiling-node": "^10.46.0",
2224
"@stellar/stellar-sdk": "^14.5.0",
2325
"@supabase/supabase-js": "^2.47.10",
2426
"@types/cookie-parser": "^1.4.10",
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
-- Add trial tracking fields to subscriptions table
2+
ALTER TABLE public.subscriptions
3+
ADD COLUMN IF NOT EXISTS trial_converts_to_price DECIMAL(10,2),
4+
ADD COLUMN IF NOT EXISTS credit_card_required BOOLEAN DEFAULT FALSE;
5+
6+
-- Note: is_trial and trial_ends_at already exist on the subscriptions table.
7+
-- price_after_trial covers trial_converts_to_price semantics but we add the
8+
-- canonical column name for clarity; both are kept for backwards compatibility.
9+
10+
-- Trial conversion events table
11+
-- Tracks whether a trial converted intentionally (user clicked "Keep") or automatically,
12+
-- and whether the user acted on reminders — used for the "Saved by SYNCRO" metric.
13+
CREATE TABLE IF NOT EXISTS public.trial_conversion_events (
14+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
15+
subscription_id UUID NOT NULL REFERENCES public.subscriptions(id) ON DELETE CASCADE,
16+
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
17+
-- 'converted' = user kept it, 'cancelled' = user cancelled before charge
18+
outcome TEXT NOT NULL CHECK (outcome IN ('converted', 'cancelled')),
19+
-- 'intentional' = user clicked Keep/Cancel, 'automatic' = trial expired without action
20+
conversion_type TEXT NOT NULL CHECK (conversion_type IN ('intentional', 'automatic')),
21+
-- true when user cancelled before auto-charge (counts toward "Saved by SYNCRO")
22+
saved_by_syncro BOOLEAN NOT NULL DEFAULT FALSE,
23+
-- which reminder (days_before) the user acted on, if any
24+
acted_on_reminder_days INTEGER,
25+
converted_price DECIMAL(10,2),
26+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
27+
);
28+
29+
ALTER TABLE public.trial_conversion_events ENABLE ROW LEVEL SECURITY;
30+
31+
CREATE POLICY "trial_conversion_events_select_own"
32+
ON public.trial_conversion_events FOR SELECT
33+
USING (auth.uid() = user_id);
34+
35+
CREATE POLICY "trial_conversion_events_insert_own"
36+
ON public.trial_conversion_events FOR INSERT
37+
WITH CHECK (auth.uid() = user_id);
38+
39+
CREATE INDEX IF NOT EXISTS trial_conversion_events_user_id_idx
40+
ON public.trial_conversion_events(user_id);
41+
42+
CREATE INDEX IF NOT EXISTS trial_conversion_events_subscription_id_idx
43+
ON public.trial_conversion_events(subscription_id);
44+
45+
CREATE INDEX IF NOT EXISTS trial_conversion_events_saved_idx
46+
ON public.trial_conversion_events(saved_by_syncro)
47+
WHERE saved_by_syncro = TRUE;

backend/src/index.ts

Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import express from 'express';
22
import cookieParser from 'cookie-parser';
33
import dotenv from 'dotenv';
4+
import * as Sentry from '@sentry/node';
5+
import { nodeProfilingIntegration } from '@sentry/profiling-node';
46
import swaggerUi from 'swagger-ui-express';
57
// Load environment variables before importing other modules
68
dotenv.config();
@@ -118,6 +120,211 @@ app.get('/api/admin/metrics/subscriptions', adminAuth, async (req, res) => {
118120
}
119121
});
120122

123+
// Load environment variables before importing other modules
124+
dotenv.config();
125+
126+
Sentry.init({
127+
dsn: process.env.SENTRY_DSN,
128+
environment: process.env.NODE_ENV,
129+
integrations: [nodeProfilingIntegration()],
130+
tracesSampleRate: 0.1,
131+
profilesSampleRate: 0.1,
132+
});
133+
134+
135+
import logger from './config/logger';
136+
import { requestIdMiddleware } from './middleware/requestContext';
137+
import { requestLoggerMiddleware } from './middleware/requestLogger';
138+
import { schedulerService } from './services/scheduler';
139+
import { reminderEngine } from './services/reminder-engine';
140+
import subscriptionRoutes from './routes/subscriptions';
141+
import riskScoreRoutes from './routes/risk-score';
142+
import simulationRoutes from './routes/simulation';
143+
import merchantRoutes from './routes/merchants';
144+
import teamRoutes from './routes/team';
145+
import auditRoutes from './routes/audit';
146+
import webhookRoutes from './routes/webhooks';
147+
import { monitoringService } from './services/monitoring-service';
148+
import { healthService } from './services/health-service';
149+
import { eventListener } from './services/event-listener';
150+
import { expiryService } from './services/expiry-service';
151+
import { scheduleAutoResume } from './jobs/auto-resume';
152+
153+
const app = express();
154+
155+
// Add Sentry request handler before routes
156+
app.use(Sentry.Handlers.requestHandler());
157+
158+
const PORT = process.env.PORT || 3001;
159+
const ADMIN_API_KEY = process.env.ADMIN_API_KEY || 'development-admin-key';
160+
161+
// CORS configuration
162+
const FRONTEND_URL = process.env.FRONTEND_URL || 'http://localhost:3000';
163+
app.use((req, res, next) => {
164+
res.header('Access-Control-Allow-Origin', FRONTEND_URL);
165+
res.header('Access-Control-Allow-Credentials', 'true');
166+
res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS');
167+
res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization, Idempotency-Key, If-Match');
168+
169+
if (req.method === 'OPTIONS') {
170+
return res.sendStatus(200);
171+
}
172+
next();
173+
});
174+
175+
// Middleware
176+
app.use(cookieParser());
177+
app.use(express.json());
178+
app.use(express.urlencoded({ extended: true }));
179+
180+
// Request tracing — must come before routes so every log line carries requestId
181+
app.use(requestIdMiddleware);
182+
app.use(requestLoggerMiddleware);
183+
184+
185+
import { adminAuth } from './middleware/admin';
186+
import { createAdminLimiter, RateLimiterFactory } from './middleware/rate-limit-factory';
187+
188+
// Health check endpoint
189+
app.get('/health', (req, res) => {
190+
res.json({ status: 'ok', timestamp: new Date().toISOString() });
191+
});
192+
193+
// API Routes
194+
app.use('/api/subscriptions', subscriptionRoutes);
195+
app.use('/api/risk-score', riskScoreRoutes);
196+
app.use('/api/simulation', simulationRoutes);
197+
app.use('/api/merchants', merchantRoutes);
198+
app.use('/api/team', teamRoutes);
199+
app.use('/api/audit', auditRoutes);
200+
app.use('/api/webhooks', webhookRoutes);
201+
202+
// API Routes (Public/Standard)
203+
app.get('/api/reminders/status', (req, res) => {
204+
const status = schedulerService.getStatus();
205+
res.json(status);
206+
});
207+
208+
// Admin Monitoring Endpoints (Read-only)
209+
app.get('/api/admin/metrics/subscriptions', createAdminLimiter(), adminAuth, async (req, res) => {
210+
try {
211+
const metrics = await monitoringService.getSubscriptionMetrics();
212+
res.json(metrics);
213+
} catch (error) {
214+
res.status(500).json({ error: 'Failed to fetch subscription metrics' });
215+
}
216+
});
217+
218+
app.get('/api/admin/metrics/renewals', createAdminLimiter(), adminAuth, async (req, res) => {
219+
try {
220+
const metrics = await monitoringService.getRenewalMetrics();
221+
res.json(metrics);
222+
} catch (error) {
223+
res.status(500).json({ error: 'Failed to fetch renewal metrics' });
224+
}
225+
});
226+
227+
app.get('/api/admin/metrics/activity', createAdminLimiter(), adminAuth, async (req, res) => {
228+
try {
229+
const metrics = await monitoringService.getAgentActivity();
230+
res.json(metrics);
231+
} catch (error) {
232+
res.status(500).json({ error: 'Failed to fetch agent activity' });
233+
}
234+
});
235+
236+
// Protocol Health Monitor: unified admin health (metrics, alerts, history)
237+
app.get('/api/admin/health', createAdminLimiter(), adminAuth, async (req, res) => {
238+
try {
239+
const includeHistory = req.query.history !== 'false';
240+
const health = await healthService.getAdminHealth(includeHistory);
241+
const statusCode = health.status === 'unhealthy' ? 503 : 200;
242+
res.status(statusCode).json(health);
243+
} catch (error) {
244+
logger.error('Error fetching admin health:', error);
245+
res.status(500).json({ error: 'Failed to fetch health status' });
246+
}
247+
});
248+
249+
// Manual trigger endpoints (for testing/admin - Should eventually be protected)
250+
app.post('/api/reminders/process', createAdminLimiter(), adminAuth, async (req, res) => {
251+
try {
252+
await reminderEngine.processReminders();
253+
res.json({ success: true, message: 'Reminders processed' });
254+
} catch (error) {
255+
logger.error('Error processing reminders:', error);
256+
res.status(500).json({
257+
success: false,
258+
error: error instanceof Error ? error.message : String(error),
259+
});
260+
}
261+
});
262+
263+
app.post('/api/reminders/schedule', createAdminLimiter(), adminAuth, async (req, res) => {
264+
try {
265+
const daysBefore = req.body.daysBefore || [7, 3, 1];
266+
await reminderEngine.scheduleReminders(daysBefore);
267+
res.json({ success: true, message: 'Reminders scheduled' });
268+
} catch (error) {
269+
logger.error('Error scheduling reminders:', error);
270+
res.status(500).json({
271+
success: false,
272+
error: error instanceof Error ? error.message : String(error),
273+
});
274+
}
275+
});
276+
277+
app.post('/api/reminders/retry', createAdminLimiter(), adminAuth, async (req, res) => {
278+
try {
279+
await reminderEngine.processRetries();
280+
res.json({ success: true, message: 'Retries processed' });
281+
} catch (error) {
282+
logger.error('Error processing retries:', error);
283+
res.status(500).json({
284+
success: false,
285+
error: error instanceof Error ? error.message : String(error),
286+
});
287+
}
288+
});
289+
290+
// Protocol Health Monitor: record metrics snapshot periodically (historical storage)
291+
const HEALTH_SNAPSHOT_INTERVAL_MS = 15 * 60 * 1000; // 15 minutes
292+
function startHealthSnapshotInterval() {
293+
setInterval(() => {
294+
healthService.recordSnapshot().catch(() => {});
295+
}, HEALTH_SNAPSHOT_INTERVAL_MS);
296+
// Record one snapshot shortly after startup
297+
setTimeout(() => healthService.recordSnapshot().catch(() => {}), 5000);
298+
}
299+
300+
app.post('/api/admin/expiry/process', createAdminLimiter(), adminAuth, async (req, res) => {
301+
try {
302+
const result = await expiryService.processExpiries();
303+
res.json({ success: true, data: result });
304+
} catch (error) {
305+
logger.error('Error processing expiries:', error);
306+
res.status(500).json({
307+
success: false,
308+
error: error instanceof Error ? error.message : String(error),
309+
});
310+
}
311+
});
312+
313+
// Add Sentry error handler after all routes
314+
app.use(Sentry.Handlers.errorHandler());
315+
316+
// Start server
317+
const server = app.listen(PORT, async () => {
318+
logger.info(`Server running on port ${PORT}`);
319+
logger.info(`Environment: ${process.env.NODE_ENV || 'development'}`);
320+
321+
// Initialize rate limiting Redis store
322+
try {
323+
await RateLimiterFactory.initializeRedisStore();
324+
logger.info('Rate limiting initialized successfully');
325+
} catch (error) {
326+
logger.warn('Rate limiting initialization failed, using memory store:', error);
327+
import * as bip39 from 'bip39';
121328
/**
122329
* @openapi
123330
* /api/admin/metrics/renewals:
@@ -132,6 +339,8 @@ app.get('/api/admin/metrics/subscriptions', adminAuth, async (req, res) => {
132339
* 401:
133340
* description: Unauthorized
134341
*/
342+
export function generateMnemonic(): string {
343+
return bip39.generateMnemonic(128);
135344
app.get('/api/admin/metrics/renewals', adminAuth, async (req, res) => {
136345
try {
137346
const metrics = await monitoringService.getRenewalMetrics();

0 commit comments

Comments
 (0)