diff --git a/backend/src/index.ts b/backend/src/index.ts index aeda4175..96a28c51 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -31,6 +31,7 @@ import { schedulerService } from './services/scheduler'; import { reminderEngine } from './services/reminder-engine'; import { notificationPreferenceService } from './services/notification-preference-service'; import subscriptionRoutes from './routes/subscriptions'; +import subscriptionShareRoutes from './routes/subscription-shares'; import riskScoreRoutes from './routes/risk-score'; import simulationRoutes from './routes/simulation'; import merchantRoutes from './routes/merchants'; @@ -72,6 +73,7 @@ import { startChannelSettlementJob } from './jobs/channel-settlement-job'; import { startJobAlertMonitor, stopJobAlertMonitor } from './jobs/job-alert-monitor'; import giftCardLedgerRoutes from './routes/gift-card-ledger'; import notificationDeadLetterRoutes from './routes/notification-dead-letter'; +import renewalDeadLetterRoutes from './routes/renewal-dead-letter'; import telegramWebhookRoutes from './routes/telegram-webhook'; import { telegramCommandService } from './services/telegram-command-service'; import calendarRouter from './routes/calendar'; @@ -171,6 +173,7 @@ app.get('/api/docs.json', (_req, res) => { // API Routes app.use('/api/keys', apiKeysRoutes); +app.use('/api/subscriptions', subscriptionShareRoutes); app.use('/api/subscriptions', subscriptionRoutes); app.use('/api/risk-score', riskScoreRoutes); app.use('/api/simulation', simulationRoutes); @@ -198,6 +201,7 @@ app.use('/api/wallet', walletRoutes); app.use('/api/key-rotation', keyRotationRoutes); app.use('/api/privacy', privacyRoutes); app.use('/api/notifications/dead-letter', notificationDeadLetterRoutes); +app.use('/api/renewals/dead-letter', renewalDeadLetterRoutes); app.use('/api/exchange-rates', createExchangeRatesRouter(exchangeRateService)); app.use('/api/gift-card-ledger', giftCardLedgerRoutes); app.use('/api/payments', authenticate, paymentsRoutes); @@ -311,6 +315,26 @@ app.get('/api/admin/metrics/failed-items', createAdminLimiter(), adminAuth, asyn } }); +app.get('/api/admin/metrics/query-cache', createAdminLimiter(), adminAuth, async (_req, res) => { + try { + const metrics = await monitoringService.getQueryCacheMetrics(); + res.json(metrics); + } catch (error) { + logger.error('Error fetching query cache metrics:', error); + res.status(500).json({ error: 'Failed to fetch query cache metrics' }); + } +}); + +app.get('/api/admin/metrics/renewal-locks', createAdminLimiter(), adminAuth, async (_req, res) => { + try { + const metrics = await monitoringService.getRenewalLockMetrics(); + res.json(metrics); + } catch (error) { + logger.error('Error fetching renewal lock metrics:', error); + res.status(500).json({ error: 'Failed to fetch renewal lock metrics' }); + } +}); + app.get('/api/admin/metrics/api-latency', createAdminLimiter(), adminAuth, async (req, res) => { try { const metrics = await monitoringService.getApiLatencyMetrics(); diff --git a/backend/src/lib/redis-client.ts b/backend/src/lib/redis-client.ts new file mode 100644 index 00000000..3363d204 --- /dev/null +++ b/backend/src/lib/redis-client.ts @@ -0,0 +1,103 @@ +import { createClient, RedisClientType } from 'redis'; +import logger from '../config/logger'; +import { rateLimitConfig } from '../config/rate-limit'; + +/** + * Shared Redis client for distributed locks, query caching, and metrics. + * Reuses the same URL as rate limiting when available, otherwise REDIS_URL. + */ +export class SharedRedisClient { + private static instance: SharedRedisClient | null = null; + private client: RedisClientType | null = null; + private isConnected = false; + private initPromise: Promise | null = null; + + private constructor() {} + + static getInstance(): SharedRedisClient { + if (!SharedRedisClient.instance) { + SharedRedisClient.instance = new SharedRedisClient(); + } + return SharedRedisClient.instance; + } + + private resolveUrl(): string | undefined { + return rateLimitConfig.redis.url || process.env.REDIS_URL; + } + + async initialize(): Promise { + if (this.isConnected && this.client) { + return; + } + if (this.initPromise) { + return this.initPromise; + } + + const url = this.resolveUrl(); + if (!url) { + logger.info('[SharedRedisClient] No Redis URL configured'); + return; + } + + this.initPromise = (async () => { + try { + this.client = createClient({ + url, + socket: { + reconnectStrategy: (retries) => Math.min(5000 * Math.pow(2, retries), 30000), + }, + }); + + this.client.on('connect', () => { + this.isConnected = true; + logger.info('[SharedRedisClient] Connected'); + }); + + this.client.on('error', (error) => { + this.isConnected = false; + logger.error('[SharedRedisClient] Error:', error); + }); + + this.client.on('disconnect', () => { + this.isConnected = false; + }); + + await this.client.connect(); + } catch (error) { + logger.error('[SharedRedisClient] Failed to initialize:', error); + this.client = null; + this.isConnected = false; + throw error; + } finally { + this.initPromise = null; + } + })(); + + return this.initPromise; + } + + async getClient(): Promise { + if (!this.client || !this.isConnected) { + try { + await this.initialize(); + } catch { + return null; + } + } + return this.client; + } + + isAvailable(): boolean { + return this.isConnected && this.client !== null; + } + + async shutdown(): Promise { + if (this.client) { + await this.client.disconnect().catch(() => undefined); + this.client = null; + this.isConnected = false; + } + } +} + +export const sharedRedisClient = SharedRedisClient.getInstance(); diff --git a/backend/src/lib/redis-lock.ts b/backend/src/lib/redis-lock.ts new file mode 100644 index 00000000..2f8235d0 --- /dev/null +++ b/backend/src/lib/redis-lock.ts @@ -0,0 +1,134 @@ +import { randomUUID } from 'crypto'; +import logger from '../config/logger'; +import { sharedRedisClient } from './redis-client'; + +export interface LockAcquireResult { + acquired: boolean; + lockToken?: string; + reason?: 'contention' | 'unavailable'; +} + +export interface LockMetricsSnapshot { + acquired: number; + contention: number; + released: number; + expired: number; +} + +const METRICS_PREFIX = 'renewal_lock:metrics:'; + +/** + * Redis-based distributed lock using SET NX with TTL. + * Default TTL: 5 minutes (300 seconds). + */ +export class RedisDistributedLock { + private readonly keyPrefix = 'renewal_lock:'; + private readonly defaultTtlMs: number; + + constructor(defaultTtlMs = 5 * 60 * 1000) { + this.defaultTtlMs = defaultTtlMs; + } + + private lockKey(subscriptionId: string, cycleId: number): string { + return `${this.keyPrefix}${subscriptionId}:${cycleId}`; + } + + private async incrementMetric(field: 'acquired' | 'contention' | 'released' | 'expired'): Promise { + const client = await sharedRedisClient.getClient(); + if (!client) return; + try { + await client.incr(`${METRICS_PREFIX}${field}`); + } catch (error) { + logger.warn('[RedisDistributedLock] Failed to increment metric', { field, error }); + } + } + + async acquire( + subscriptionId: string, + cycleId: number, + ttlMs: number = this.defaultTtlMs, + ): Promise { + const client = await sharedRedisClient.getClient(); + if (!client) { + logger.warn('[RedisDistributedLock] Redis unavailable, lock not acquired'); + return { acquired: false, reason: 'unavailable' }; + } + + const token = randomUUID(); + const key = this.lockKey(subscriptionId, cycleId); + const ttlSeconds = Math.max(1, Math.ceil(ttlMs / 1000)); + + try { + const result = await client.set(key, token, { NX: true, EX: ttlSeconds }); + if (result === 'OK') { + await this.incrementMetric('acquired'); + logger.info('[RedisDistributedLock] Lock acquired', { subscriptionId, cycleId, ttlSeconds }); + return { acquired: true, lockToken: token }; + } + + await this.incrementMetric('contention'); + logger.warn('[RedisDistributedLock] Lock contention', { subscriptionId, cycleId }); + return { acquired: false, reason: 'contention' }; + } catch (error) { + logger.error('[RedisDistributedLock] Acquire failed', { subscriptionId, cycleId, error }); + return { acquired: false, reason: 'unavailable' }; + } + } + + /** + * Release lock only if we still hold it (compare token via Lua script). + */ + async release(subscriptionId: string, cycleId: number, lockToken: string): Promise { + const client = await sharedRedisClient.getClient(); + if (!client) return false; + + const key = this.lockKey(subscriptionId, cycleId); + const script = ` + if redis.call("get", KEYS[1]) == ARGV[1] then + return redis.call("del", KEYS[1]) + else + return 0 + end + `; + + try { + const result = await client.eval(script, { keys: [key], arguments: [lockToken] }); + if (result === 1) { + await this.incrementMetric('released'); + logger.info('[RedisDistributedLock] Lock released', { subscriptionId, cycleId }); + return true; + } + await this.incrementMetric('expired'); + return false; + } catch (error) { + logger.error('[RedisDistributedLock] Release failed', { subscriptionId, cycleId, error }); + return false; + } + } + + async getMetrics(): Promise { + const client = await sharedRedisClient.getClient(); + if (!client) { + return { acquired: 0, contention: 0, released: 0, expired: 0 }; + } + + const fields: Array = ['acquired', 'contention', 'released', 'expired']; + const values = await Promise.all( + fields.map(async (field) => { + const val = await client.get(`${METRICS_PREFIX}${field}`); + return parseInt(val ?? '0', 10) || 0; + }), + ); + + return { + acquired: values[0], + contention: values[1], + released: values[2], + expired: values[3], + }; + } +} + +export const redisDistributedLock = new RedisDistributedLock( + parseInt(process.env.RENEWAL_LOCK_TTL_MS ?? '300000', 10) || 5 * 60 * 1000, +); diff --git a/backend/src/routes/renewal-dead-letter.ts b/backend/src/routes/renewal-dead-letter.ts new file mode 100644 index 00000000..fe5c71ed --- /dev/null +++ b/backend/src/routes/renewal-dead-letter.ts @@ -0,0 +1,45 @@ +import { Router, Response } from 'express'; +import { authenticate, AuthenticatedRequest } from '../middleware/auth'; +import { adminAuth } from '../middleware/admin'; +import { renewalDeadLetterService } from '../services/renewal-dead-letter-service'; +import logger from '../config/logger'; + +const router: Router = Router(); + +router.use(authenticate); + +/** + * GET /api/renewals/dead-letter + * List renewal dead-letter entries for the authenticated user. + */ +router.get('/', async (req: AuthenticatedRequest, res: Response) => { + try { + const entries = await renewalDeadLetterService.getUserDeadLetters(req.user!.id); + res.json({ success: true, data: entries }); + } catch (error) { + logger.error('GET /api/renewals/dead-letter error:', error); + res.status(500).json({ + success: false, + error: error instanceof Error ? error.message : 'Failed to fetch renewal dead-letter entries', + }); + } +}); + +/** + * GET /api/renewals/dead-letter/stats + * Admin-only aggregate DLQ statistics. + */ +router.get('/stats', adminAuth, async (_req: AuthenticatedRequest, res: Response) => { + try { + const stats = await renewalDeadLetterService.getDeadLetterStats(); + res.json({ success: true, data: stats }); + } catch (error) { + logger.error('GET /api/renewals/dead-letter/stats error:', error); + res.status(500).json({ + success: false, + error: error instanceof Error ? error.message : 'Failed to fetch renewal DLQ stats', + }); + } +}); + +export default router; diff --git a/backend/src/routes/subscription-shares.ts b/backend/src/routes/subscription-shares.ts new file mode 100644 index 00000000..4b783dca --- /dev/null +++ b/backend/src/routes/subscription-shares.ts @@ -0,0 +1,126 @@ +import { Router, Response } from 'express'; +import { authenticate, AuthenticatedRequest } from '../middleware/auth'; +import { validate } from '../middleware/validate'; +import { subscriptionShareService } from '../services/subscription-share-service'; +import { createShareInviteSchema } from '../schemas/subscription-share'; +import logger from '../config/logger'; + +const router: Router = Router(); + +/** + * GET /api/subscriptions/share/:token + * Public preview of a share invite (rate-limited at app level). + */ +router.get('/share/:token', async (req, res: Response) => { + try { + const preview = await subscriptionShareService.getInvitePreview(req.params.token); + res.json({ success: true, data: preview }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Invalid invite'; + const status = message.includes('expired') ? 410 : 404; + res.status(status).json({ success: false, error: message }); + } +}); + +router.use(authenticate); + +/** + * POST /api/subscriptions/:id/share + * Create a secure share invite for a subscription. + */ +router.post('/:id/share', validate(createShareInviteSchema), async (req: AuthenticatedRequest, res: Response) => { + try { + const result = await subscriptionShareService.createInvite( + req.user!.id, + req.params.id, + req.body, + ); + + res.status(201).json({ + success: true, + data: { + id: result.invite.id, + shareUrl: result.shareUrl, + permissionLevel: result.invite.permission_level, + expiresAt: result.invite.expires_at, + maxUses: result.invite.max_uses >= 999999 ? 'unlimited' : result.invite.max_uses, + }, + }); + } catch (error) { + logger.error('POST /api/subscriptions/:id/share error:', error); + res.status(400).json({ + success: false, + error: error instanceof Error ? error.message : 'Failed to create share invite', + }); + } +}); + +/** + * POST /api/subscriptions/share/:token/accept + * Accept a share invite (authenticated). + */ +router.post('/share/:token/accept', async (req: AuthenticatedRequest, res: Response) => { + try { + const result = await subscriptionShareService.acceptInvite(req.params.token, req.user!.id); + res.json({ success: true, data: result }); + } catch (error) { + logger.error('POST /api/subscriptions/share/:token/accept error:', error); + res.status(400).json({ + success: false, + error: error instanceof Error ? error.message : 'Failed to accept invite', + }); + } +}); + +/** + * GET /api/subscriptions/:id/share + * List pending share invites for a subscription. + */ +router.get('/:id/share', async (req: AuthenticatedRequest, res: Response) => { + try { + const invites = await subscriptionShareService.listPendingInvites(req.user!.id, req.params.id); + res.json({ success: true, data: invites }); + } catch (error) { + logger.error('GET /api/subscriptions/:id/share error:', error); + res.status(400).json({ + success: false, + error: error instanceof Error ? error.message : 'Failed to list share invites', + }); + } +}); + +/** + * DELETE /api/subscriptions/:id/share/:inviteId + * Revoke a pending share invite. + */ +router.delete('/:id/share/:inviteId', async (req: AuthenticatedRequest, res: Response) => { + try { + await subscriptionShareService.revokeInvite(req.user!.id, req.params.inviteId); + res.json({ success: true, message: 'Invite revoked' }); + } catch (error) { + logger.error('DELETE /api/subscriptions/:id/share/:inviteId error:', error); + res.status(400).json({ + success: false, + error: error instanceof Error ? error.message : 'Failed to revoke invite', + }); + } +}); + +/** + * GET /api/subscriptions/:id/share/audit + * Audit log of invite usage for a subscription. + */ +router.get('/:id/share/audit', async (req: AuthenticatedRequest, res: Response) => { + try { + const log = await subscriptionShareService.getAuditLog(req.user!.id, req.params.id); + res.json({ success: true, data: log }); + } catch (error) { + logger.error('GET /api/subscriptions/:id/share/audit error:', error); + res.status(400).json({ + success: false, + error: error instanceof Error ? error.message : 'Failed to fetch audit log', + }); + } +}); + +export default router; diff --git a/backend/src/routes/telegram-webhook.ts b/backend/src/routes/telegram-webhook.ts index b89e3daf..69b04544 100644 --- a/backend/src/routes/telegram-webhook.ts +++ b/backend/src/routes/telegram-webhook.ts @@ -254,7 +254,7 @@ router.post('/webhook', validateWebhookSecret, async (req: Request, res: Respons if (text === '/help') { await telegramBotService.sendSimpleMessage( '', - `SYNCRO Bot Commands\n\n/start - Connect your SYNCRO account\n/disconnect - Disconnect your account\n/help - Show this help message\n\nAbout SYNCRO\nSYNCRO helps you manage your subscriptions and never miss a renewal.\n\nVisit: https://syncro.app`, + `SYNCRO Bot Commands\n\n/start - Connect your SYNCRO account\n/disconnect - Disconnect your account\n/status - Subscription overview\n/subs - List active subscriptions\n/renewals - Upcoming renewals\n/snooze - Snooze a reminder\n/help - Show this help message\n\nAbout SYNCRO\nSYNCRO helps you manage your subscriptions and never miss a renewal.\n\nVisit: https://syncro.app`, chatId ); diff --git a/backend/src/schemas/subscription-share.ts b/backend/src/schemas/subscription-share.ts new file mode 100644 index 00000000..96bdbbf6 --- /dev/null +++ b/backend/src/schemas/subscription-share.ts @@ -0,0 +1,11 @@ +import { z } from 'zod'; + +export const createShareInviteSchema = z.object({ + expiry: z.enum(['1h', '24h', '7d', '30d']).default('7d'), + maxUses: z.union([z.literal(1), z.literal(-1)]).default(1), + permissionLevel: z.enum(['view-only', 'can-renew', 'full-access']).default('view-only'), +}); + +export const acceptShareInviteSchema = z.object({}); + +export type CreateShareInviteInput = z.infer; diff --git a/backend/src/services/analytics-service.ts b/backend/src/services/analytics-service.ts index e21b63cd..1c00f14b 100644 --- a/backend/src/services/analytics-service.ts +++ b/backend/src/services/analytics-service.ts @@ -11,12 +11,18 @@ import { } from '@syncro/shared/subscription-math'; import { AnalyticsSummary, MonthlySpend, CategorySpend, SubscriptionSpend, Budget } from '../types/analytics'; import { Subscription } from '../types/reminder'; +import { queryCacheService } from './query-cache-service'; export class AnalyticsService { /** * Get analytics summary for a user */ async getSummary(userId: string): Promise { + const cached = await queryCacheService.get(userId, 'analytics_summary', { type: 'summary' }); + if (cached) { + return cached; + } + try { // 1. Fetch active subscriptions const { data: subscriptions, error: subError } = await supabase @@ -53,7 +59,7 @@ export class AnalyticsService { const upcomingRenewalsCount = countUpcomingRenewals(typedSubs, 7); - return { + const summary = { total_monthly_spend: totalMonthlySpend, active_subscriptions: typedSubs.length, upcoming_renewals_count: upcomingRenewalsCount, @@ -62,6 +68,16 @@ export class AnalyticsService { top_subscriptions: topSubscriptions, budget_status: budgetStatus }; + + await queryCacheService.set( + userId, + 'analytics_summary', + { type: 'summary' }, + summary, + queryCacheService.getDefaultAnalyticsTtl(), + ); + + return summary; } catch (error) { logger.error('Error fetching analytics summary:', error); throw error; @@ -129,6 +145,8 @@ export class AnalyticsService { throw error; } + await queryCacheService.invalidateUserNamespace(userId, 'analytics_summary'); + return data; } diff --git a/backend/src/services/monitoring-service.ts b/backend/src/services/monitoring-service.ts index 55684a85..351ccc08 100644 --- a/backend/src/services/monitoring-service.ts +++ b/backend/src/services/monitoring-service.ts @@ -2,6 +2,9 @@ import { supabase, monitorPool, PoolMetrics } from '../config/database'; import logger from '../config/logger'; import { ExternalServiceClient, ServiceMetrics } from '../utils/external-service-client'; import { apiLatencyService, EndpointLatencyMetrics } from './api-latency-service'; +import { redisDistributedLock, LockMetricsSnapshot } from '../lib/redis-lock'; +import { renewalDeadLetterService } from './renewal-dead-letter-service'; +import { queryCacheService, QueryCacheMetrics } from './query-cache-service'; import { normalizeToMonthlyAmount } from '@syncro/shared/subscription-math'; // ─── Existing interfaces ──────────────────────────────────────────────────── @@ -110,6 +113,16 @@ export interface FailedItemsResult { items: FailedItem[]; } +export interface RenewalLockMetrics { + redis_locks: LockMetricsSnapshot; + dead_letter: { + total: number; + last_24h: number; + last_7d: number; + }; + generated_at: string; +} + // ─── Service class ─────────────────────────────────────────────────────────── export class MonitoringService { @@ -658,6 +671,36 @@ export class MonitoringService { async getApiLatencyMetrics(): Promise { return apiLatencyService.getLatencyMetrics(); } + + /** + * Renewal distributed-lock metrics for the ops dashboard (Issue #962). + */ + async getRenewalLockMetrics(): Promise { + const [redisLocks, deadLetter] = await Promise.all([ + redisDistributedLock.getMetrics(), + renewalDeadLetterService.getDeadLetterStats(), + ]); + + return { + redis_locks: redisLocks, + dead_letter: deadLetter, + generated_at: new Date().toISOString(), + }; + } + + /** + * Query cache hit/miss metrics (Issue #941). + */ + async getQueryCacheMetrics(): Promise { + const metrics = await queryCacheService.getMetrics(); + const total = metrics.hits + metrics.misses; + const hitRate = total > 0 ? parseFloat(((metrics.hits / total) * 100).toFixed(2)) : 0; + + return { + ...metrics, + hit_rate_pct: hitRate, + }; + } } export const monitoringService = new MonitoringService(); diff --git a/backend/src/services/query-cache-service.ts b/backend/src/services/query-cache-service.ts new file mode 100644 index 00000000..b8d2f35d --- /dev/null +++ b/backend/src/services/query-cache-service.ts @@ -0,0 +1,150 @@ +import crypto from 'crypto'; +import logger from '../config/logger'; +import { sharedRedisClient } from '../lib/redis-client'; + +export interface QueryCacheMetrics { + hits: number; + misses: number; + invalidations: number; +} + +export interface QueryCacheOptions { + ttlMs: number; + namespace: string; +} + +const METRICS_PREFIX = 'query_cache:metrics:'; +const KEY_PREFIX = 'query_cache:'; + +/** + * Redis-backed query result cache with per-user key isolation (RLS-safe). + */ +export class QueryCacheService { + private readonly enabled: boolean; + private inMemoryMetrics: QueryCacheMetrics = { hits: 0, misses: 0, invalidations: 0 }; + + constructor() { + this.enabled = process.env.QUERY_CACHE_ENABLED !== 'false'; + } + + private cacheKey(userId: string, namespace: string, queryHash: string): string { + return `${KEY_PREFIX}${namespace}:${userId}:${queryHash}`; + } + + private hashQuery(payload: unknown): string { + return crypto.createHash('sha256').update(JSON.stringify(payload)).digest('hex').slice(0, 16); + } + + private async incrementMetric(field: keyof QueryCacheMetrics): Promise { + this.inMemoryMetrics[field]++; + + const client = await sharedRedisClient.getClient(); + if (!client) return; + + try { + await client.incr(`${METRICS_PREFIX}${field}`); + } catch (error) { + logger.warn('[QueryCache] Failed to increment metric', { field, error }); + } + } + + async get(userId: string, namespace: string, queryPayload: unknown): Promise { + if (!this.enabled) return null; + + const client = await sharedRedisClient.getClient(); + if (!client) return null; + + const key = this.cacheKey(userId, namespace, this.hashQuery(queryPayload)); + + try { + const raw = await client.get(key); + if (raw) { + await this.incrementMetric('hits'); + return JSON.parse(raw) as T; + } + await this.incrementMetric('misses'); + return null; + } catch (error) { + logger.warn('[QueryCache] Get failed', { namespace, error }); + return null; + } + } + + async set( + userId: string, + namespace: string, + queryPayload: unknown, + value: T, + ttlMs: number, + ): Promise { + if (!this.enabled) return; + + const client = await sharedRedisClient.getClient(); + if (!client) return; + + const key = this.cacheKey(userId, namespace, this.hashQuery(queryPayload)); + const ttlSeconds = Math.max(1, Math.ceil(ttlMs / 1000)); + + try { + await client.set(key, JSON.stringify(value), { EX: ttlSeconds }); + } catch (error) { + logger.warn('[QueryCache] Set failed', { namespace, error }); + } + } + + /** + * Invalidate all cached entries for a user within a namespace. + */ + async invalidateUserNamespace(userId: string, namespace: string): Promise { + const client = await sharedRedisClient.getClient(); + if (!client) return; + + const pattern = `${KEY_PREFIX}${namespace}:${userId}:*`; + + try { + let cursor = '0'; + do { + const result = await client.scan(cursor, { MATCH: pattern, COUNT: 100 }); + cursor = result.cursor; + if (result.keys.length > 0) { + await client.del(result.keys); + } + } while (cursor !== '0'); + + await this.incrementMetric('invalidations'); + } catch (error) { + logger.warn('[QueryCache] Invalidation failed', { namespace, userId, error }); + } + } + + async getMetrics(): Promise { + const client = await sharedRedisClient.getClient(); + if (!client) { + return { ...this.inMemoryMetrics }; + } + + const fields: Array = ['hits', 'misses', 'invalidations']; + const values = await Promise.all( + fields.map(async (field) => { + const val = await client.get(`${METRICS_PREFIX}${field}`); + return parseInt(val ?? String(this.inMemoryMetrics[field]), 10) || 0; + }), + ); + + return { + hits: values[0], + misses: values[1], + invalidations: values[2], + }; + } + + getDefaultSubscriptionListTtl(): number { + return parseInt(process.env.QUERY_CACHE_SUBSCRIPTION_LIST_TTL_MS ?? '60000', 10) || 60_000; + } + + getDefaultAnalyticsTtl(): number { + return parseInt(process.env.QUERY_CACHE_ANALYTICS_TTL_MS ?? '300000', 10) || 300_000; + } +} + +export const queryCacheService = new QueryCacheService(); diff --git a/backend/src/services/reminder-engine.ts b/backend/src/services/reminder-engine.ts index 1979d612..68c469a0 100644 --- a/backend/src/services/reminder-engine.ts +++ b/backend/src/services/reminder-engine.ts @@ -15,6 +15,7 @@ import { subDays } from 'date-fns'; import { calculateBackoffDelay } from '../utils/retry'; import { userPreferenceService } from './user-preference-service'; import { notificationPreferenceService } from './notification-preference-service'; +import { telegramBotService } from './telegram-bot-service'; export interface ReminderEngineOptions { defaultDaysBefore?: number[]; @@ -393,6 +394,30 @@ export class ReminderEngine { ); } + if (deliveryChannels.includes('telegram') && userPreferences.email_opt_ins.reminders) { + const subPrefs = await this.getNotificationPreferences(reminder.subscription_id, reminder.user_id); + if (!subPrefs.muted && subPrefs.channels.includes('telegram')) { + const telegramDelivery = await this.createDeliveryRecord(reminder.id, reminder.user_id, 'telegram'); + deliveries.push(telegramDelivery); + + const telegramResult = await telegramBotService.sendRenewalReminder(reminder.user_id, payload, undefined, { + maxAttempts: this.maxRetryAttempts, + }); + const telegramStatus: DeliveryStatus = telegramResult.success + ? 'sent' + : (telegramResult.metadata?.retryable ? 'retrying' : 'failed'); + + telegramDelivery.status = telegramStatus; + + await this.updateDeliveryRecord( + telegramDelivery.id, + telegramStatus, + telegramResult.error, + telegramResult.metadata, + ); + } + } + await blockchainService.logReminderEvent(reminder.user_id, payload, deliveryChannels); const hasDeliveryProgress = deliveries.some((delivery) => @@ -472,6 +497,10 @@ export class ReminderEngine { result = await slackService.sendReminderNotification(payload, { maxAttempts: 1, }); + } else if (delivery.channel === 'telegram') { + result = await telegramBotService.sendRenewalReminder(delivery.user_id, payload, undefined, { + maxAttempts: 1, + }); } if (result.success) { diff --git a/backend/src/services/renewal-dead-letter-service.ts b/backend/src/services/renewal-dead-letter-service.ts new file mode 100644 index 00000000..07888d60 --- /dev/null +++ b/backend/src/services/renewal-dead-letter-service.ts @@ -0,0 +1,214 @@ +import { supabase } from '../config/database'; +import logger from '../config/logger'; + +export interface RenewalDeadLetterEntry { + id: string; + subscription_id: string; + user_id: string; + cycle_id: number; + idempotency_key: string; + approval_id: string | null; + amount: number | null; + failure_count: number; + last_error_message: string | null; + last_failure_reason: string | null; + dead_letter_at: string; + created_at: string; + updated_at: string; +} + +export interface RenewalAttemptRecord { + id: string; + subscription_id: string; + user_id: string; + cycle_id: number; + idempotency_key: string; + status: 'pending' | 'processing' | 'success' | 'failed' | 'skipped'; + lock_holder: string | null; + result: unknown; + created_at: string; + updated_at: string; +} + +/** + * Dead-letter queue for stuck or repeatedly failed renewal executions. + */ +export class RenewalDeadLetterService { + async recordAttempt(params: { + subscriptionId: string; + userId: string; + cycleId: number; + idempotencyKey: string; + lockHolder: string; + }): Promise { + const { data, error } = await supabase + .from('renewal_attempts') + .insert({ + subscription_id: params.subscriptionId, + user_id: params.userId, + cycle_id: params.cycleId, + idempotency_key: params.idempotencyKey, + status: 'processing', + lock_holder: params.lockHolder, + }) + .select() + .single(); + + if (error) { + if (error.code === '23505') { + const { data: existing } = await supabase + .from('renewal_attempts') + .select('*') + .eq('idempotency_key', params.idempotencyKey) + .single(); + return existing as RenewalAttemptRecord | null; + } + logger.error('[RenewalDLQ] Failed to record attempt', { error }); + return null; + } + + return data as RenewalAttemptRecord; + } + + async updateAttemptStatus( + idempotencyKey: string, + status: RenewalAttemptRecord['status'], + result?: unknown, + ): Promise { + const { error } = await supabase + .from('renewal_attempts') + .update({ + status, + result: result ?? null, + updated_at: new Date().toISOString(), + }) + .eq('idempotency_key', idempotencyKey); + + if (error) { + logger.error('[RenewalDLQ] Failed to update attempt status', { idempotencyKey, error }); + } + } + + async getAttemptByKey(idempotencyKey: string): Promise { + const { data, error } = await supabase + .from('renewal_attempts') + .select('*') + .eq('idempotency_key', idempotencyKey) + .maybeSingle(); + + if (error) { + logger.error('[RenewalDLQ] Failed to fetch attempt', { idempotencyKey, error }); + return null; + } + + return data as RenewalAttemptRecord | null; + } + + async moveToDeadLetter(params: { + subscriptionId: string; + userId: string; + cycleId: number; + idempotencyKey: string; + approvalId?: string; + amount?: number; + failureReason: string; + errorMessage?: string; + }): Promise { + const { data: existing } = await supabase + .from('renewal_dead_letter_queue') + .select('id, failure_count') + .eq('idempotency_key', params.idempotencyKey) + .maybeSingle(); + + if (existing) { + const { data, error } = await supabase + .from('renewal_dead_letter_queue') + .update({ + failure_count: (existing.failure_count ?? 0) + 1, + last_failure_reason: params.failureReason, + last_error_message: params.errorMessage ?? null, + dead_letter_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }) + .eq('id', existing.id) + .select() + .single(); + + if (error) throw error; + logger.warn('[RenewalDLQ] Updated existing DLQ entry', { id: existing.id }); + return data as RenewalDeadLetterEntry; + } + + const { data, error } = await supabase + .from('renewal_dead_letter_queue') + .insert({ + subscription_id: params.subscriptionId, + user_id: params.userId, + cycle_id: params.cycleId, + idempotency_key: params.idempotencyKey, + approval_id: params.approvalId ?? null, + amount: params.amount ?? null, + last_failure_reason: params.failureReason, + last_error_message: params.errorMessage ?? null, + }) + .select() + .single(); + + if (error) { + logger.error('[RenewalDLQ] Failed to move to dead-letter', { error }); + throw error; + } + + logger.warn('[RenewalDLQ] Renewal moved to dead-letter', { + subscriptionId: params.subscriptionId, + cycleId: params.cycleId, + }); + + return data as RenewalDeadLetterEntry; + } + + async getUserDeadLetters(userId: string): Promise { + const { data, error } = await supabase + .from('renewal_dead_letter_queue') + .select('*') + .eq('user_id', userId) + .order('dead_letter_at', { ascending: false }); + + if (error) { + logger.error('[RenewalDLQ] Failed to fetch user DLQ entries', { error }); + throw error; + } + + return (data ?? []) as RenewalDeadLetterEntry[]; + } + + async getDeadLetterStats(): Promise<{ + total: number; + last_24h: number; + last_7d: number; + }> { + const now = Date.now(); + const since24h = new Date(now - 24 * 60 * 60 * 1000).toISOString(); + const since7d = new Date(now - 7 * 24 * 60 * 60 * 1000).toISOString(); + + const [totalRes, last24hRes, last7dRes] = await Promise.all([ + supabase.from('renewal_dead_letter_queue').select('id', { count: 'exact', head: true }), + supabase + .from('renewal_dead_letter_queue') + .select('id', { count: 'exact', head: true }) + .gte('dead_letter_at', since24h), + supabase + .from('renewal_dead_letter_queue') + .select('id', { count: 'exact', head: true }) + .gte('dead_letter_at', since7d), + ]); + + return { + total: totalRes.count ?? 0, + last_24h: last24hRes.count ?? 0, + last_7d: last7dRes.count ?? 0, + }; + } +} + +export const renewalDeadLetterService = new RenewalDeadLetterService(); diff --git a/backend/src/services/renewal-execution-orchestrator.ts b/backend/src/services/renewal-execution-orchestrator.ts new file mode 100644 index 00000000..4c8df478 --- /dev/null +++ b/backend/src/services/renewal-execution-orchestrator.ts @@ -0,0 +1,181 @@ +import { randomUUID } from 'crypto'; +import logger from '../config/logger'; +import { supabase } from '../config/database'; +import { redisDistributedLock } from '../lib/redis-lock'; +import { renewalExecutor } from './renewal-executor'; +import { renewalDeadLetterService } from './renewal-dead-letter-service'; +import { generateCycleId } from '../utils/cycle-id'; +import { idempotencyService } from './idempotency'; + +export interface RenewalExecutionRequest { + subscriptionId: string; + userId: string; + approvalId: string; + amount: number; + billingDate?: Date | string; +} + +export interface RenewalExecutionResponse { + success: boolean; + subscriptionId: string; + idempotencyKey: string; + cycleId: number; + transactionHash?: string; + skipped?: boolean; + reason?: string; + error?: string; + failureReason?: string; +} + +/** + * Orchestrates idempotent renewal execution with Redis distributed locks. + */ +export class RenewalExecutionOrchestrator { + private readonly lockHolder = `worker-${process.pid}-${randomUUID().slice(0, 8)}`; + + async executeIdempotentRenewal(request: RenewalExecutionRequest): Promise { + const { subscriptionId, userId, approvalId, amount } = request; + + const billingDate = request.billingDate ?? (await this.resolveBillingDate(subscriptionId)); + const cycleId = generateCycleId(billingDate); + const idempotencyKey = idempotencyService.generateKey(userId, `renewal:${subscriptionId}:${cycleId}`, { + subscriptionId, + approvalId, + amount, + cycleId, + }); + + const existingAttempt = await renewalDeadLetterService.getAttemptByKey(idempotencyKey); + if (existingAttempt?.status === 'success' && existingAttempt.result) { + logger.info('[RenewalOrchestrator] Idempotent hit — returning cached result', { + subscriptionId, + cycleId, + idempotencyKey, + }); + const cached = existingAttempt.result as RenewalExecutionResponse; + return { ...cached, skipped: true, reason: 'already_processed' }; + } + + const lockResult = await redisDistributedLock.acquire(subscriptionId, cycleId); + if (!lockResult.acquired) { + if (lockResult.reason === 'contention') { + logger.info('[RenewalOrchestrator] Lock contention — skipping duplicate renewal', { + subscriptionId, + cycleId, + }); + return { + success: false, + subscriptionId, + idempotencyKey, + cycleId, + skipped: true, + reason: 'lock_contention', + error: 'Another worker is processing this renewal', + }; + } + + logger.warn('[RenewalOrchestrator] Redis unavailable — proceeding without lock', { + subscriptionId, + cycleId, + }); + } + + const lockToken = lockResult.lockToken; + + try { + await renewalDeadLetterService.recordAttempt({ + subscriptionId, + userId, + cycleId, + idempotencyKey, + lockHolder: this.lockHolder, + }); + + const result = await renewalExecutor.executeRenewal({ + subscriptionId, + userId, + approvalId, + amount, + }); + + const response: RenewalExecutionResponse = { + success: result.success, + subscriptionId, + idempotencyKey, + cycleId, + transactionHash: result.transactionHash, + error: result.error, + failureReason: result.failureReason, + }; + + if (result.success) { + await renewalDeadLetterService.updateAttemptStatus(idempotencyKey, 'success', response); + + const requestHash = idempotencyService.hashRequest({ subscriptionId, approvalId, amount, cycleId }); + await idempotencyService.storeResponse(idempotencyKey, userId, requestHash, 200, response); + } else { + await renewalDeadLetterService.updateAttemptStatus(idempotencyKey, 'failed', response); + + const isStuck = ['execution_error', 'contract_failure'].includes(result.failureReason ?? ''); + if (isStuck) { + await renewalDeadLetterService.moveToDeadLetter({ + subscriptionId, + userId, + cycleId, + idempotencyKey, + approvalId, + amount, + failureReason: result.failureReason ?? 'unknown', + errorMessage: result.error, + }); + } + } + + return response; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + logger.error('[RenewalOrchestrator] Unexpected error', { subscriptionId, error: errorMsg }); + + await renewalDeadLetterService.updateAttemptStatus(idempotencyKey, 'failed', { error: errorMsg }); + await renewalDeadLetterService.moveToDeadLetter({ + subscriptionId, + userId, + cycleId, + idempotencyKey, + approvalId, + amount, + failureReason: 'execution_error', + errorMessage: errorMsg, + }); + + return { + success: false, + subscriptionId, + idempotencyKey, + cycleId, + error: errorMsg, + failureReason: 'execution_error', + }; + } finally { + if (lockToken) { + await redisDistributedLock.release(subscriptionId, cycleId, lockToken); + } + } + } + + private async resolveBillingDate(subscriptionId: string): Promise { + const { data, error } = await supabase + .from('subscriptions') + .select('next_billing_date') + .eq('id', subscriptionId) + .single(); + + if (error || !data?.next_billing_date) { + return new Date().toISOString(); + } + + return data.next_billing_date; + } +} + +export const renewalExecutionOrchestrator = new RenewalExecutionOrchestrator(); diff --git a/backend/src/services/renewal-executor.ts b/backend/src/services/renewal-executor.ts index 9de0b314..b43e0da3 100644 --- a/backend/src/services/renewal-executor.ts +++ b/backend/src/services/renewal-executor.ts @@ -348,6 +348,30 @@ export class RenewalExecutor { logger.info('Renewal executed successfully', { subscriptionId, transactionHash }); + // Telegram payment confirmation (non-blocking) + try { + const { telegramNotificationService } = await import('./telegram-notification-service'); + const { data: sub } = await supabase + .from('subscriptions') + .select('name, price, currency, billing_cycle') + .eq('id', subscriptionId) + .single(); + + if (sub) { + telegramNotificationService + .sendPaymentConfirmation(userId, { + subscriptionName: sub.name, + amount: sub.price, + currency: sub.currency, + billingCycle: sub.billing_cycle, + transactionHash, + }) + .catch((err) => logger.warn('Telegram payment confirmation failed', { err })); + } + } catch { + // non-blocking + } + // Dispatch webhook event try { webhookService.dispatchEvent(userId, 'subscription.renewed', { diff --git a/backend/src/services/scheduler.ts b/backend/src/services/scheduler.ts index 3b671795..ec2a9b9a 100644 --- a/backend/src/services/scheduler.ts +++ b/backend/src/services/scheduler.ts @@ -13,6 +13,7 @@ import { idempotencyService } from './idempotency'; import { subscriptionService } from './subscription-service'; import { jobAlertService } from './job-alert-service'; import { agentWalletRotationService } from './agent-wallet-rotation'; +import { telegramNotificationService } from './telegram-notification-service'; export class SchedulerService { private jobs: cron.ScheduledTask[] = []; @@ -132,6 +133,20 @@ export class SchedulerService { }), ); + // ── Weekly on Monday at 9 AM UTC: Telegram spending summaries ──────── + this.jobs.push( + cron.schedule('0 9 * * 1', async () => { + logger.info('Running weekly Telegram spending summaries'); + try { + await jobAlertService.runMonitoredJob('telegram-weekly-summary', () => + telegramNotificationService.sendWeeklySummariesToAllUsers(), + ); + } catch (error) { + logger.error('Error in weekly Telegram summary job:', error); + } + }), + ); + // ── 1st of every month at 8 AM UTC: monthly digest ─────────────────── // Cron: minute=0, hour=8, day=1, month=*, weekday=* this.jobs.push( diff --git a/backend/src/services/subscription-service.ts b/backend/src/services/subscription-service.ts index de16b1f1..4aecc677 100644 --- a/backend/src/services/subscription-service.ts +++ b/backend/src/services/subscription-service.ts @@ -16,6 +16,7 @@ import type { ListSubscriptionsOptions, ListSubscriptionsResult, } from "../types/subscription"; +import { queryCacheService } from "./query-cache-service"; export interface BlockchainSyncResult { success: boolean; @@ -37,7 +38,7 @@ export class SubscriptionService { userId: string, input: SubscriptionCreateInput, ): Promise { - return await DatabaseTransaction.execute(async (client) => { + const result = await DatabaseTransaction.execute(async (client) => { try { // Determine the next stealth derivation index for this user const { data: indexRow } = await client @@ -155,6 +156,9 @@ export class SubscriptionService { throw error; } }); + + await this.invalidateSubscriptionCache(userId); + return result; } /** @@ -165,7 +169,7 @@ export class SubscriptionService { userId: string, subscriptionId: string, ): Promise { - return await DatabaseTransaction.execute(async (client) => { + const result = await DatabaseTransaction.execute(async (client) => { try { // 1. Verify ownership and get subscription details const { data: existing, error: fetchError } = await client @@ -257,6 +261,9 @@ export class SubscriptionService { throw error; } }); + + await this.invalidateSubscriptionCache(userId); + return result; } async cancelSubscription( @@ -629,7 +636,7 @@ export class SubscriptionService { input: SubscriptionUpdateInput, expectedVersion?: number, ): Promise { - return await DatabaseTransaction.execute(async (client) => { + const result = await DatabaseTransaction.execute(async (client) => { try { // 1. Fetch and verify ownership const { data: existing, error: fetchError } = await client @@ -711,12 +718,21 @@ export class SubscriptionService { throw error; } }); + + await this.invalidateSubscriptionCache(userId); + return result; } /** * Get subscription by ID (with ownership check) */ async getSubscription(userId: string, subscriptionId: string): Promise { + const cacheKey = { subscriptionId }; + const cached = await queryCacheService.get(userId, 'subscription_detail', cacheKey); + if (cached) { + return cached; + } + const { data: subscription, error } = await supabase .from("subscriptions") .select("*") @@ -728,6 +744,14 @@ export class SubscriptionService { throw new Error("Subscription not found or access denied"); } + await queryCacheService.set( + userId, + 'subscription_detail', + cacheKey, + subscription, + queryCacheService.getDefaultSubscriptionListTtl(), + ); + return subscription; } @@ -738,6 +762,16 @@ export class SubscriptionService { userId: string, options: ListSubscriptionsOptions = {}, ): Promise { + const cachePayload = { ...options }; + const cached = await queryCacheService.get( + userId, + 'subscription_list', + cachePayload, + ); + if (cached) { + return cached; + } + const limit = Math.min(options.limit ?? 20, 100); const validatedCursor = validateCursor(options.cursor); @@ -779,12 +813,30 @@ if (error) { ? encodeCursor({ createdAt: subscriptions[subscriptions.length - 1].created_at }) : null; - return { + const result = { subscriptions, total: count ?? 0, hasMore, nextCursor, }; + + await queryCacheService.set( + userId, + 'subscription_list', + cachePayload, + result, + queryCacheService.getDefaultSubscriptionListTtl(), + ); + + return result; + } + + private async invalidateSubscriptionCache(userId: string): Promise { + await Promise.all([ + queryCacheService.invalidateUserNamespace(userId, 'subscription_list'), + queryCacheService.invalidateUserNamespace(userId, 'subscription_detail'), + queryCacheService.invalidateUserNamespace(userId, 'analytics_summary'), + ]); } diff --git a/backend/src/services/subscription-share-service.ts b/backend/src/services/subscription-share-service.ts new file mode 100644 index 00000000..22c43d13 --- /dev/null +++ b/backend/src/services/subscription-share-service.ts @@ -0,0 +1,275 @@ +import crypto from 'crypto'; +import { supabase } from '../config/database'; +import logger from '../config/logger'; +import type { CreateShareInviteInput } from '../schemas/subscription-share'; + +export type SharePermissionLevel = 'view-only' | 'can-renew' | 'full-access'; + +export interface ShareInvite { + id: string; + subscription_id: string; + created_by: string; + token_hash: string; + permission_level: SharePermissionLevel; + max_uses: number; + use_count: number; + expires_at: string; + revoked_at: string | null; + created_at: string; + updated_at: string; +} + +export interface ShareInvitePublicView { + subscriptionId: string; + subscriptionName: string; + permissionLevel: SharePermissionLevel; + expiresAt: string; + usesRemaining: number | null; +} + +const EXPIRY_MS: Record = { + '1h': 60 * 60 * 1000, + '24h': 24 * 60 * 60 * 1000, + '7d': 7 * 24 * 60 * 60 * 1000, + '30d': 30 * 24 * 60 * 60 * 1000, +}; + +export class SubscriptionShareService { + private hashToken(token: string): string { + return crypto.createHash('sha256').update(token).digest('hex'); + } + + private generateToken(): string { + return crypto.randomBytes(32).toString('base64url'); + } + + async createInvite( + userId: string, + subscriptionId: string, + input: CreateShareInviteInput, + ): Promise<{ invite: ShareInvite; token: string; shareUrl: string }> { + const { data: subscription, error: subErr } = await supabase + .from('subscriptions') + .select('id, name, user_id') + .eq('id', subscriptionId) + .eq('user_id', userId) + .single(); + + if (subErr || !subscription) { + throw new Error('Subscription not found or access denied'); + } + + const token = this.generateToken(); + const tokenHash = this.hashToken(token); + const expiresAt = new Date(Date.now() + (EXPIRY_MS[input.expiry] ?? EXPIRY_MS['7d'])); + const maxUses = input.maxUses === -1 ? 999999 : 1; + + const { data: invite, error } = await supabase + .from('subscription_share_invites') + .insert({ + subscription_id: subscriptionId, + created_by: userId, + token_hash: tokenHash, + permission_level: input.permissionLevel, + max_uses: maxUses, + expires_at: expiresAt.toISOString(), + }) + .select() + .single(); + + if (error || !invite) { + logger.error('[SubscriptionShare] Failed to create invite', { error }); + throw new Error('Failed to create share invite'); + } + + await this.logAudit(invite.id, subscriptionId, 'created', userId, { + permissionLevel: input.permissionLevel, + expiry: input.expiry, + maxUses: input.maxUses, + }); + + const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:3000'; + const shareUrl = `${frontendUrl}/share/${token}`; + + return { invite: invite as ShareInvite, token, shareUrl }; + } + + async getInvitePreview(token: string): Promise { + const tokenHash = this.hashToken(token); + + const { data: invite, error } = await supabase + .from('subscription_share_invites') + .select('*, subscriptions!inner(id, name)') + .eq('token_hash', tokenHash) + .is('revoked_at', null) + .single(); + + if (error || !invite) { + throw new Error('Invite not found or has been revoked'); + } + + if (new Date(invite.expires_at) < new Date()) { + await this.logAudit(invite.id, invite.subscription_id, 'expired', null); + throw new Error('Invite has expired'); + } + + if (invite.use_count >= invite.max_uses) { + throw new Error('Invite has reached its usage limit'); + } + + const sub = invite.subscriptions as { id: string; name: string }; + + await this.logAudit(invite.id, invite.subscription_id, 'viewed', null); + + return { + subscriptionId: sub.id, + subscriptionName: sub.name, + permissionLevel: invite.permission_level, + expiresAt: invite.expires_at, + usesRemaining: invite.max_uses >= 999999 ? null : invite.max_uses - invite.use_count, + }; + } + + async acceptInvite(token: string, userId: string): Promise<{ + subscriptionId: string; + permissionLevel: SharePermissionLevel; + }> { + const tokenHash = this.hashToken(token); + + const { data: invite, error } = await supabase + .from('subscription_share_invites') + .select('*') + .eq('token_hash', tokenHash) + .is('revoked_at', null) + .single(); + + if (error || !invite) { + throw new Error('Invite not found or has been revoked'); + } + + if (new Date(invite.expires_at) < new Date()) { + throw new Error('Invite has expired'); + } + + if (invite.use_count >= invite.max_uses) { + throw new Error('Invite has reached its usage limit'); + } + + if (invite.created_by === userId) { + throw new Error('Cannot accept your own invite'); + } + + const { error: updateErr } = await supabase + .from('subscription_share_invites') + .update({ + use_count: invite.use_count + 1, + updated_at: new Date().toISOString(), + }) + .eq('id', invite.id); + + if (updateErr) { + throw new Error('Failed to accept invite'); + } + + await this.logAudit(invite.id, invite.subscription_id, 'accepted', userId, { + permissionLevel: invite.permission_level, + }); + + return { + subscriptionId: invite.subscription_id, + permissionLevel: invite.permission_level, + }; + } + + async revokeInvite(userId: string, inviteId: string): Promise { + const { data: invite, error } = await supabase + .from('subscription_share_invites') + .select('*') + .eq('id', inviteId) + .eq('created_by', userId) + .is('revoked_at', null) + .single(); + + if (error || !invite) { + throw new Error('Invite not found or already revoked'); + } + + const { error: updateErr } = await supabase + .from('subscription_share_invites') + .update({ + revoked_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }) + .eq('id', inviteId); + + if (updateErr) { + throw new Error('Failed to revoke invite'); + } + + await this.logAudit(inviteId, invite.subscription_id, 'revoked', userId); + } + + async listPendingInvites(userId: string, subscriptionId: string): Promise { + const { data, error } = await supabase + .from('subscription_share_invites') + .select('*') + .eq('subscription_id', subscriptionId) + .eq('created_by', userId) + .is('revoked_at', null) + .gt('expires_at', new Date().toISOString()) + .order('created_at', { ascending: false }); + + if (error) { + throw new Error('Failed to list share invites'); + } + + return (data ?? []) as ShareInvite[]; + } + + async getAuditLog(userId: string, subscriptionId: string): Promise { + const { data: subscription } = await supabase + .from('subscriptions') + .select('id') + .eq('id', subscriptionId) + .eq('user_id', userId) + .single(); + + if (!subscription) { + throw new Error('Subscription not found or access denied'); + } + + const { data, error } = await supabase + .from('subscription_share_audit_log') + .select('*') + .eq('subscription_id', subscriptionId) + .order('created_at', { ascending: false }); + + if (error) { + throw new Error('Failed to fetch audit log'); + } + + return data ?? []; + } + + private async logAudit( + inviteId: string, + subscriptionId: string, + action: string, + actorUserId: string | null, + metadata?: Record, + ): Promise { + const { error } = await supabase.from('subscription_share_audit_log').insert({ + invite_id: inviteId, + subscription_id: subscriptionId, + action, + actor_user_id: actorUserId, + metadata: metadata ?? null, + }); + + if (error) { + logger.warn('[SubscriptionShare] Audit log insert failed', { error }); + } + } +} + +export const subscriptionShareService = new SubscriptionShareService(); diff --git a/backend/src/services/telegram-command-service.ts b/backend/src/services/telegram-command-service.ts index e5e9a2d3..de894b30 100644 --- a/backend/src/services/telegram-command-service.ts +++ b/backend/src/services/telegram-command-service.ts @@ -504,6 +504,77 @@ export async function handleSnoozeCommand(ctx: Context): Promise { }); } +export async function handleStatusCommand(ctx: Context): Promise { + const requestId = randomUUID(); + const chatId = String(ctx.chat?.id); + + logger.info('[TelegramCommandService] /status command received', { requestId, chatId }); + + let userId: string | null; + try { + userId = await getUserIdByChatId(chatId); + } catch (err) { + logger.error('[TelegramCommandService] DB error looking up user for /status', { + requestId, + chatId, + error: (err as Error).message, + }); + await ctx.reply('⚠️ Something went wrong. Please try again later.'); + return; + } + + if (!userId) { + await ctx.reply( + '🔗 Your Telegram account is not linked to a SYNCRO account.\n\n' + + 'Log in to SYNCRO and connect Telegram in your notification settings.', + ); + return; + } + + let subs: Subscription[]; + let renewals: UpcomingRenewal[]; + try { + [subs, renewals] = await Promise.all([ + getActiveSubscriptions(userId), + getUpcomingRenewals(userId), + ]); + } catch (err) { + logger.error('[TelegramCommandService] DB error fetching status', { + requestId, + userId, + error: (err as Error).message, + }); + await ctx.reply('⚠️ Could not load your subscription overview. Please try again later.'); + return; + } + + const totalMonthly = subs.reduce( + (sum, s) => sum + toMonthlyAmount(s.price, s.billing_cycle), + 0, + ); + const currency = subs[0]?.currency ?? 'USD'; + const nextRenewal = renewals[0]; + + let message = '📊 Subscription Overview\n\n'; + message += `📦 Active: ${subs.length}\n`; + message += `💳 Monthly spend: ${currency} ${totalMonthly.toFixed(2)}\n`; + message += `📅 Upcoming renewals (30d): ${renewals.length}\n`; + + if (nextRenewal) { + const dateStr = new Date(nextRenewal.next_billing_date).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + }); + message += `\n⏭ Next: ${nextRenewal.name} on ${dateStr}`; + } + + message += '\n\n_Use /renewals for the full list or /snooze to pause reminders._'; + + await ctx.reply(message, { parse_mode: 'HTML' }); + + logger.info('[TelegramCommandService] /status reply sent', { requestId, userId }); +} + // ─── Service ────────────────────────────────────────────────────────────────── export class TelegramCommandService { @@ -523,6 +594,7 @@ export class TelegramCommandService { this.bot.command('subs', handleSubsCommand); this.bot.command('renewals', handleRenewalsCommand); this.bot.command('snooze', handleSnoozeCommand); + this.bot.command('status', handleStatusCommand); logger.info('[TelegramCommandService] Command handlers registered'); return this.bot; diff --git a/backend/src/services/telegram-notification-service.ts b/backend/src/services/telegram-notification-service.ts new file mode 100644 index 00000000..a91db5f6 --- /dev/null +++ b/backend/src/services/telegram-notification-service.ts @@ -0,0 +1,130 @@ +import logger from '../config/logger'; +import { supabase } from '../config/database'; +import { telegramBotService } from './telegram-bot-service'; +import { userPreferenceService } from './user-preference-service'; +import { normalizeToMonthlyAmount } from '@syncro/shared/subscription-math'; +import type { Subscription } from '../types/subscription'; + +/** + * Telegram notification delivery for renewals, payments, and spending summaries. + */ +export class TelegramNotificationService { + async isTelegramEnabled(userId: string): Promise { + const prefs = await userPreferenceService.getPreferences(userId); + return prefs.notification_channels.includes('telegram'); + } + + async sendPaymentConfirmation( + userId: string, + params: { + subscriptionName: string; + amount: number; + currency?: string; + billingCycle?: string; + transactionHash?: string; + }, + ): Promise { + if (!(await this.isTelegramEnabled(userId))) { + return; + } + + const currency = params.currency ?? 'USD'; + const message = [ + '✅ Payment Confirmed', + '', + `${params.subscriptionName}`, + `💰 Amount: ${currency} ${params.amount.toFixed(2)}/${params.billingCycle ?? 'period'}`, + params.transactionHash ? `🔗 Tx: ${params.transactionHash.slice(0, 16)}…` : '', + ] + .filter(Boolean) + .join('\n'); + + await telegramBotService.sendSimpleMessage(userId, message); + } + + async sendWeeklySpendingSummary(userId: string): Promise { + if (!(await this.isTelegramEnabled(userId))) { + return; + } + + const prefs = await userPreferenceService.getPreferences(userId); + if (!prefs.email_opt_ins?.reminders) { + logger.debug('[TelegramNotification] User opted out of reminder notifications'); + return; + } + + const { data: subscriptions, error } = await supabase + .from('subscriptions') + .select('name, price, currency, billing_cycle, status') + .eq('user_id', userId) + .eq('status', 'active'); + + if (error) { + logger.error('[TelegramNotification] Failed to fetch subscriptions for weekly summary', { error }); + return; + } + + const subs = (subscriptions ?? []) as Subscription[]; + if (subs.length === 0) { + await telegramBotService.sendSimpleMessage( + userId, + '📊 Weekly Spending Summary\n\nNo active subscriptions this week.', + ); + return; + } + + const totalMonthly = subs.reduce( + (sum, s) => sum + normalizeToMonthlyAmount(s.price, s.billing_cycle), + 0, + ); + + const topThree = [...subs] + .sort((a, b) => normalizeToMonthlyAmount(b.price, b.billing_cycle) - normalizeToMonthlyAmount(a.price, a.billing_cycle)) + .slice(0, 3); + + const lines = topThree.map( + (s, i) => + `${i + 1}. ${s.name} — ${s.currency} ${normalizeToMonthlyAmount(s.price, s.billing_cycle).toFixed(2)}/mo`, + ); + + const message = [ + '📊 Weekly Spending Summary', + '', + `💳 Total: ${subs[0]?.currency ?? 'USD'} ${totalMonthly.toFixed(2)}/mo`, + `📦 Active subscriptions: ${subs.length}`, + '', + 'Top spenders:', + ...lines, + ].join('\n'); + + await telegramBotService.sendSimpleMessage(userId, message); + } + + async sendWeeklySummariesToAllUsers(): Promise { + const { data: connections, error } = await supabase + .from('user_telegram_connections') + .select('user_id'); + + if (error || !connections?.length) { + return 0; + } + + let sent = 0; + for (const conn of connections) { + try { + await this.sendWeeklySpendingSummary(conn.user_id); + sent++; + } catch (err) { + logger.error('[TelegramNotification] Weekly summary failed', { + userId: conn.user_id, + error: err instanceof Error ? err.message : String(err), + }); + } + } + + logger.info('[TelegramNotification] Weekly summaries sent', { sent }); + return sent; + } +} + +export const telegramNotificationService = new TelegramNotificationService(); diff --git a/backend/tests/query-cache-service.test.ts b/backend/tests/query-cache-service.test.ts new file mode 100644 index 00000000..ce0b14de --- /dev/null +++ b/backend/tests/query-cache-service.test.ts @@ -0,0 +1,77 @@ +jest.mock('../src/lib/redis-client', () => ({ + sharedRedisClient: { + getClient: jest.fn(), + }, +})); + +jest.mock('../src/config/logger', () => ({ + default: { info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() }, + __esModule: true, +})); + +import { QueryCacheService } from '../src/services/query-cache-service'; +import { sharedRedisClient } from '../src/lib/redis-client'; + +describe('QueryCacheService', () => { + let service: QueryCacheService; + let mockClient: { + get: jest.Mock; + set: jest.Mock; + incr: jest.Mock; + scan: jest.Mock; + del: jest.Mock; + }; + + beforeEach(() => { + jest.clearAllMocks(); + mockClient = { + get: jest.fn(), + set: jest.fn(), + incr: jest.fn(), + scan: jest.fn().mockResolvedValue({ cursor: '0', keys: [] }), + del: jest.fn(), + }; + (sharedRedisClient.getClient as jest.Mock).mockResolvedValue(mockClient); + service = new QueryCacheService(); + }); + + it('returns cached value on hit', async () => { + mockClient.get.mockResolvedValue(JSON.stringify({ total: 5 })); + + const result = await service.get<{ total: number }>('user-1', 'subscription_list', { page: 1 }); + + expect(result).toEqual({ total: 5 }); + expect(mockClient.incr).toHaveBeenCalledWith('query_cache:metrics:hits'); + }); + + it('returns null on cache miss', async () => { + mockClient.get.mockResolvedValue(null); + + const result = await service.get('user-1', 'subscription_list', { page: 1 }); + + expect(result).toBeNull(); + expect(mockClient.incr).toHaveBeenCalledWith('query_cache:metrics:misses'); + }); + + it('stores value with TTL', async () => { + await service.set('user-1', 'analytics_summary', { type: 'summary' }, { spend: 100 }, 300000); + + expect(mockClient.set).toHaveBeenCalledWith( + expect.stringContaining('query_cache:analytics_summary:user-1:'), + JSON.stringify({ spend: 100 }), + { EX: 300 }, + ); + }); + + it('scopes cache keys per user for RLS safety', async () => { + mockClient.get.mockResolvedValue(null); + + await service.get('user-a', 'subscription_list', {}); + await service.get('user-b', 'subscription_list', {}); + + const keys = mockClient.get.mock.calls.map((c) => c[0]); + expect(keys[0]).toContain(':user-a:'); + expect(keys[1]).toContain(':user-b:'); + expect(keys[0]).not.toEqual(keys[1]); + }); +}); diff --git a/backend/tests/renewal-execution-orchestrator.test.ts b/backend/tests/renewal-execution-orchestrator.test.ts new file mode 100644 index 00000000..e870792a --- /dev/null +++ b/backend/tests/renewal-execution-orchestrator.test.ts @@ -0,0 +1,146 @@ +jest.mock('../src/config/database', () => ({ + supabase: { from: jest.fn() }, +})); + +jest.mock('../src/config/logger', () => ({ + default: { info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() }, + __esModule: true, +})); + +jest.mock('../src/lib/redis-lock', () => ({ + redisDistributedLock: { + acquire: jest.fn(), + release: jest.fn(), + getMetrics: jest.fn(), + }, +})); + +jest.mock('../src/services/renewal-executor', () => ({ + renewalExecutor: { executeRenewal: jest.fn() }, +})); + +jest.mock('../src/services/renewal-dead-letter-service', () => ({ + renewalDeadLetterService: { + getAttemptByKey: jest.fn(), + recordAttempt: jest.fn(), + updateAttemptStatus: jest.fn(), + moveToDeadLetter: jest.fn(), + }, +})); + +jest.mock('../src/services/idempotency', () => ({ + idempotencyService: { + generateKey: jest.fn(), + hashRequest: jest.fn(), + storeResponse: jest.fn(), + }, +})); + +import { RenewalExecutionOrchestrator } from '../src/services/renewal-execution-orchestrator'; +import { redisDistributedLock } from '../src/lib/redis-lock'; +import { renewalExecutor } from '../src/services/renewal-executor'; +import { renewalDeadLetterService } from '../src/services/renewal-dead-letter-service'; +import { idempotencyService } from '../src/services/idempotency'; +import { supabase } from '../src/config/database'; + +describe('RenewalExecutionOrchestrator', () => { + let orchestrator: RenewalExecutionOrchestrator; + + const request = { + subscriptionId: 'sub-1', + userId: 'user-1', + approvalId: 'approval-1', + amount: 9.99, + billingDate: '2026-06-27', + }; + + beforeEach(() => { + jest.clearAllMocks(); + orchestrator = new RenewalExecutionOrchestrator(); + + (idempotencyService.generateKey as jest.Mock).mockReturnValue('idem-key-1'); + (idempotencyService.hashRequest as jest.Mock).mockReturnValue('hash-1'); + (renewalDeadLetterService.getAttemptByKey as jest.Mock).mockResolvedValue(null); + (renewalDeadLetterService.recordAttempt as jest.Mock).mockResolvedValue({ id: 'attempt-1' }); + (renewalDeadLetterService.updateAttemptStatus as jest.Mock).mockResolvedValue(undefined); + (renewalDeadLetterService.moveToDeadLetter as jest.Mock).mockResolvedValue({ id: 'dlq-1' }); + (redisDistributedLock.acquire as jest.Mock).mockResolvedValue({ acquired: true, lockToken: 'token-1' }); + (redisDistributedLock.release as jest.Mock).mockResolvedValue(true); + }); + + it('executes renewal when lock is acquired', async () => { + (renewalExecutor.executeRenewal as jest.Mock).mockResolvedValue({ + success: true, + subscriptionId: 'sub-1', + transactionHash: 'tx-abc', + }); + + const result = await orchestrator.executeIdempotentRenewal(request); + + expect(result.success).toBe(true); + expect(result.transactionHash).toBe('tx-abc'); + expect(result.idempotencyKey).toBe('idem-key-1'); + expect(redisDistributedLock.acquire).toHaveBeenCalled(); + expect(redisDistributedLock.release).toHaveBeenCalledWith('sub-1', 20260627, 'token-1'); + expect(renewalDeadLetterService.updateAttemptStatus).toHaveBeenCalledWith('idem-key-1', 'success', expect.any(Object)); + }); + + it('returns cached result for already successful attempt', async () => { + (renewalDeadLetterService.getAttemptByKey as jest.Mock).mockResolvedValue({ + status: 'success', + result: { success: true, subscriptionId: 'sub-1', transactionHash: 'tx-cached' }, + }); + + const result = await orchestrator.executeIdempotentRenewal(request); + + expect(result.skipped).toBe(true); + expect(result.reason).toBe('already_processed'); + expect(renewalExecutor.executeRenewal).not.toHaveBeenCalled(); + }); + + it('handles lock contention gracefully', async () => { + (redisDistributedLock.acquire as jest.Mock).mockResolvedValue({ acquired: false, reason: 'contention' }); + + const result = await orchestrator.executeIdempotentRenewal(request); + + expect(result.skipped).toBe(true); + expect(result.reason).toBe('lock_contention'); + expect(renewalExecutor.executeRenewal).not.toHaveBeenCalled(); + }); + + it('moves failed renewals to dead-letter queue', async () => { + (renewalExecutor.executeRenewal as jest.Mock).mockResolvedValue({ + success: false, + subscriptionId: 'sub-1', + failureReason: 'contract_failure', + error: 'RPC timeout', + }); + + const result = await orchestrator.executeIdempotentRenewal(request); + + expect(result.success).toBe(false); + expect(renewalDeadLetterService.moveToDeadLetter).toHaveBeenCalledWith( + expect.objectContaining({ + subscriptionId: 'sub-1', + failureReason: 'contract_failure', + }), + ); + }); + + it('resolves billing date from subscription when not provided', async () => { + (supabase.from as jest.Mock).mockReturnValue({ + select: jest.fn().mockReturnThis(), + eq: jest.fn().mockReturnThis(), + single: jest.fn().mockResolvedValue({ + data: { next_billing_date: '2026-07-01' }, + error: null, + }), + }); + (renewalExecutor.executeRenewal as jest.Mock).mockResolvedValue({ success: true, subscriptionId: 'sub-1' }); + + const { billingDate: _billingDate, ...reqWithoutDate } = request; + await orchestrator.executeIdempotentRenewal(reqWithoutDate); + + expect(supabase.from).toHaveBeenCalledWith('subscriptions'); + }); +}); diff --git a/backend/tests/subscription-share-service.test.ts b/backend/tests/subscription-share-service.test.ts new file mode 100644 index 00000000..ffcf94c1 --- /dev/null +++ b/backend/tests/subscription-share-service.test.ts @@ -0,0 +1,94 @@ +jest.mock('../src/config/database', () => ({ + supabase: { from: jest.fn() }, +})); + +jest.mock('../src/config/logger', () => ({ + default: { info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() }, + __esModule: true, +})); + +import { SubscriptionShareService } from '../src/services/subscription-share-service'; +import { supabase } from '../src/config/database'; + +describe('SubscriptionShareService', () => { + let service: SubscriptionShareService; + + beforeEach(() => { + jest.clearAllMocks(); + service = new SubscriptionShareService(); + }); + + function mockSubscriptionLookup() { + (supabase.from as jest.Mock).mockImplementation((table: string) => { + if (table === 'subscriptions') { + return { + select: jest.fn().mockReturnThis(), + eq: jest.fn().mockReturnThis(), + single: jest.fn().mockResolvedValue({ + data: { id: 'sub-1', name: 'Netflix', user_id: 'user-1' }, + error: null, + }), + }; + } + if (table === 'subscription_share_invites') { + return { + insert: jest.fn().mockReturnThis(), + select: jest.fn().mockReturnThis(), + single: jest.fn().mockResolvedValue({ + data: { + id: 'invite-1', + subscription_id: 'sub-1', + created_by: 'user-1', + permission_level: 'view-only', + max_uses: 1, + use_count: 0, + expires_at: new Date(Date.now() + 86400000).toISOString(), + }, + error: null, + }), + }; + } + if (table === 'subscription_share_audit_log') { + return { insert: jest.fn().mockResolvedValue({ error: null }) }; + } + return { select: jest.fn(), eq: jest.fn(), single: jest.fn() }; + }); + } + + it('creates a share invite with secure token', async () => { + mockSubscriptionLookup(); + + const result = await service.createInvite('user-1', 'sub-1', { + expiry: '24h', + maxUses: 1, + permissionLevel: 'view-only', + }); + + expect(result.token).toBeTruthy(); + expect(result.token.length).toBeGreaterThan(20); + expect(result.shareUrl).toContain('/share/'); + expect(result.invite.permission_level).toBe('view-only'); + }); + + it('rejects accept when invite is expired', async () => { + (supabase.from as jest.Mock).mockReturnValue({ + select: jest.fn().mockReturnThis(), + eq: jest.fn().mockReturnThis(), + is: jest.fn().mockReturnThis(), + single: jest.fn().mockResolvedValue({ + data: { + id: 'invite-1', + subscription_id: 'sub-1', + created_by: 'user-2', + permission_level: 'view-only', + max_uses: 1, + use_count: 0, + expires_at: new Date(Date.now() - 1000).toISOString(), + }, + error: null, + }), + }); + + await expect(service.acceptInvite('some-token', 'user-1')).rejects.toThrow('expired'); + }); +}); diff --git a/backend/tests/telegram-notification-service.test.ts b/backend/tests/telegram-notification-service.test.ts new file mode 100644 index 00000000..81ae3505 --- /dev/null +++ b/backend/tests/telegram-notification-service.test.ts @@ -0,0 +1,91 @@ +jest.mock('../src/config/database', () => ({ + supabase: { from: jest.fn() }, +})); + +jest.mock('../src/config/logger', () => ({ + default: { info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() }, + __esModule: true, +})); + +jest.mock('../src/services/telegram-bot-service', () => ({ + telegramBotService: { + sendSimpleMessage: jest.fn().mockResolvedValue({ success: true }), + sendRenewalReminder: jest.fn().mockResolvedValue({ success: true }), + }, +})); + +jest.mock('../src/services/user-preference-service', () => ({ + userPreferenceService: { + getPreferences: jest.fn().mockResolvedValue({ + notification_channels: ['telegram'], + email_opt_ins: { reminders: true }, + }), + }, +})); + +import { TelegramNotificationService } from '../src/services/telegram-notification-service'; +import { telegramBotService } from '../src/services/telegram-bot-service'; +import { userPreferenceService } from '../src/services/user-preference-service'; +import { supabase } from '../src/config/database'; + +describe('TelegramNotificationService', () => { + let service: TelegramNotificationService; + + beforeEach(() => { + jest.clearAllMocks(); + (userPreferenceService.getPreferences as jest.Mock).mockResolvedValue({ + notification_channels: ['telegram'], + email_opt_ins: { reminders: true }, + }); + service = new TelegramNotificationService(); + }); + + it('sends payment confirmation when telegram is enabled', async () => { + await service.sendPaymentConfirmation('user-1', { + subscriptionName: 'Netflix', + amount: 15.99, + currency: 'USD', + billingCycle: 'monthly', + transactionHash: 'abc123def456', + }); + + expect(telegramBotService.sendSimpleMessage).toHaveBeenCalledWith( + 'user-1', + expect.stringContaining('Payment Confirmed'), + ); + }); + + it('skips payment confirmation when telegram channel disabled', async () => { + (userPreferenceService.getPreferences as jest.Mock).mockResolvedValue({ + notification_channels: ['email'], + email_opt_ins: { reminders: true }, + }); + + await service.sendPaymentConfirmation('user-1', { + subscriptionName: 'Netflix', + amount: 15.99, + }); + + expect(telegramBotService.sendSimpleMessage).not.toHaveBeenCalled(); + }); + + it('sends weekly spending summary with subscription totals', async () => { + const eqMock = jest.fn().mockResolvedValue({ + data: [ + { name: 'Netflix', price: 15.99, currency: 'USD', billing_cycle: 'monthly', status: 'active' }, + { name: 'Spotify', price: 9.99, currency: 'USD', billing_cycle: 'monthly', status: 'active' }, + ], + error: null, + }); + (supabase.from as jest.Mock).mockReturnValue({ + select: jest.fn().mockReturnValue({ eq: jest.fn().mockReturnValue({ eq: eqMock }) }), + }); + + await service.sendWeeklySpendingSummary('user-1'); + + expect(telegramBotService.sendSimpleMessage).toHaveBeenCalledWith( + 'user-1', + expect.stringContaining('Weekly Spending Summary'), + ); + }); +}); diff --git a/supabase/migrations/20260627000001_create_renewal_dead_letter_queue.sql b/supabase/migrations/20260627000001_create_renewal_dead_letter_queue.sql new file mode 100644 index 00000000..11120efc --- /dev/null +++ b/supabase/migrations/20260627000001_create_renewal_dead_letter_queue.sql @@ -0,0 +1,47 @@ +-- Renewal dead-letter queue for stuck/failed renewal executions (Issue #962) +CREATE TABLE IF NOT EXISTS renewal_dead_letter_queue ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + subscription_id UUID NOT NULL, + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + cycle_id INTEGER NOT NULL, + idempotency_key TEXT NOT NULL, + approval_id TEXT, + amount NUMERIC(12, 2), + failure_count INTEGER NOT NULL DEFAULT 1, + last_error_message TEXT, + last_failure_reason TEXT, + dead_letter_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_renewal_dlq_subscription ON renewal_dead_letter_queue(subscription_id); +CREATE INDEX IF NOT EXISTS idx_renewal_dlq_user ON renewal_dead_letter_queue(user_id); +CREATE INDEX IF NOT EXISTS idx_renewal_dlq_created ON renewal_dead_letter_queue(created_at); +CREATE UNIQUE INDEX IF NOT EXISTS idx_renewal_dlq_idempotency ON renewal_dead_letter_queue(idempotency_key); + +-- Tracks each renewal attempt with its idempotency key +CREATE TABLE IF NOT EXISTS renewal_attempts ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + subscription_id UUID NOT NULL, + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + cycle_id INTEGER NOT NULL, + idempotency_key TEXT NOT NULL UNIQUE, + status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'processing', 'success', 'failed', 'skipped')), + lock_holder TEXT, + result JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_renewal_attempts_subscription_cycle + ON renewal_attempts(subscription_id, cycle_id); + +ALTER TABLE renewal_dead_letter_queue ENABLE ROW LEVEL SECURITY; +ALTER TABLE renewal_attempts ENABLE ROW LEVEL SECURITY; + +CREATE POLICY renewal_dlq_user_access ON renewal_dead_letter_queue + FOR SELECT USING (user_id = auth.uid()); + +CREATE POLICY renewal_attempts_user_access ON renewal_attempts + FOR SELECT USING (user_id = auth.uid()); diff --git a/supabase/migrations/20260627000002_create_subscription_share_invites.sql b/supabase/migrations/20260627000002_create_subscription_share_invites.sql new file mode 100644 index 00000000..01aba428 --- /dev/null +++ b/supabase/migrations/20260627000002_create_subscription_share_invites.sql @@ -0,0 +1,43 @@ +-- Subscription sharing via secure invite links (Issue #968) +CREATE TABLE IF NOT EXISTS subscription_share_invites ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + subscription_id UUID NOT NULL, + created_by UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + token_hash TEXT NOT NULL UNIQUE, + permission_level TEXT NOT NULL DEFAULT 'view-only' + CHECK (permission_level IN ('view-only', 'can-renew', 'full-access')), + max_uses INTEGER NOT NULL DEFAULT 1, + use_count INTEGER NOT NULL DEFAULT 0, + expires_at TIMESTAMPTZ NOT NULL, + revoked_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_share_invites_subscription ON subscription_share_invites(subscription_id); +CREATE INDEX IF NOT EXISTS idx_share_invites_created_by ON subscription_share_invites(created_by); +CREATE INDEX IF NOT EXISTS idx_share_invites_expires ON subscription_share_invites(expires_at); + +CREATE TABLE IF NOT EXISTS subscription_share_audit_log ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + invite_id UUID NOT NULL REFERENCES subscription_share_invites(id) ON DELETE CASCADE, + subscription_id UUID NOT NULL, + action TEXT NOT NULL CHECK (action IN ('created', 'accepted', 'revoked', 'expired', 'viewed')), + actor_user_id UUID REFERENCES auth.users(id) ON DELETE SET NULL, + metadata JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_share_audit_invite ON subscription_share_audit_log(invite_id); +CREATE INDEX IF NOT EXISTS idx_share_audit_subscription ON subscription_share_audit_log(subscription_id); + +ALTER TABLE subscription_share_invites ENABLE ROW LEVEL SECURITY; +ALTER TABLE subscription_share_audit_log ENABLE ROW LEVEL SECURITY; + +CREATE POLICY share_invites_owner_access ON subscription_share_invites + FOR ALL USING (created_by = auth.uid()); + +CREATE POLICY share_audit_owner_access ON subscription_share_audit_log + FOR SELECT USING ( + invite_id IN (SELECT id FROM subscription_share_invites WHERE created_by = auth.uid()) + );