diff --git a/.github/workflows/database.yml b/.github/workflows/database.yml new file mode 100644 index 0000000..f0a4ed6 --- /dev/null +++ b/.github/workflows/database.yml @@ -0,0 +1,40 @@ +name: Database Migrations + +on: + push: + branches: [main, develop] + paths: + - 'supabase/migrations/**' + - 'supabase/config.toml' + pull_request: + branches: [main, develop] + paths: + - 'supabase/migrations/**' + - 'supabase/config.toml' + +jobs: + validate-migrations: + name: Validate Migrations + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Supabase CLI + uses: supabase/setup-cli@v1 + with: + version: latest + + - name: Start Supabase local stack + run: supabase start + + - name: Apply all migrations + run: supabase db push + + - name: Lint SQL migrations + run: supabase db lint + + - name: Stop Supabase local stack + if: always() + run: supabase stop diff --git a/.kiro/specs/slack-notifications/.config.kiro b/.kiro/specs/slack-notifications/.config.kiro new file mode 100644 index 0000000..b38db8f --- /dev/null +++ b/.kiro/specs/slack-notifications/.config.kiro @@ -0,0 +1 @@ +{"specId": "7fce59ae-4f86-43e1-b223-34de74af24d0", "workflowType": "requirements-first", "specType": "feature"} \ No newline at end of file diff --git a/.kiro/specs/slack-notifications/requirements.md b/.kiro/specs/slack-notifications/requirements.md new file mode 100644 index 0000000..0171599 --- /dev/null +++ b/.kiro/specs/slack-notifications/requirements.md @@ -0,0 +1,87 @@ +# Requirements Document + +## Introduction + +This feature adds Slack notification support to SYNCRO, allowing users to receive renewal reminders and risk alerts directly in their Slack workspace via Incoming Webhooks. Users configure a Slack webhook URL in their notification settings, and SYNCRO posts richly formatted messages with actionable buttons when subscriptions are approaching renewal or when risk conditions are detected. Slack delivery is additive — failures must not interfere with existing email delivery. + +## Glossary + +- **Slack_Service**: The SYNCRO backend service responsible for constructing and delivering messages to Slack via webhook URLs. +- **Webhook_URL**: A Slack-provided HTTPS endpoint that accepts POST requests to deliver messages to a specific Slack channel or workspace. +- **Reminder_Engine**: The existing SYNCRO component that schedules and dispatches renewal reminder notifications. +- **Risk_Alert_Engine**: The existing SYNCRO component that detects and dispatches risk condition alerts for subscriptions. +- **Renewal_Reminder**: A notification informing the user that a subscription is approaching its renewal date. +- **Risk_Alert**: A notification informing the user that a subscription has entered a risk condition (e.g., approval expiring). +- **Block_Kit_Message**: A Slack message formatted using Slack's Block Kit JSON structure, supporting sections, buttons, and markdown. +- **Notification_Settings**: The SYNCRO user interface and backend configuration where users manage their notification preferences, including the Webhook_URL. +- **User**: A SYNCRO account holder who manages subscriptions. +- **Subscription**: A tracked software or service contract managed within SYNCRO. + +--- + +## Requirements + +### Requirement 1: Webhook URL Configuration + +**User Story:** As a User, I want to add my Slack webhook URL in SYNCRO notification settings, so that I can receive SYNCRO alerts in my Slack workspace. + +#### Acceptance Criteria + +1. THE Notification_Settings SHALL provide a field for the User to input a Webhook_URL. +2. WHEN the User submits a Webhook_URL, THE Slack_Service SHALL send a test POST request to the provided Webhook_URL before saving. +3. WHEN the test POST request returns an HTTP 200 response, THE Notification_Settings SHALL save the Webhook_URL to the User's profile. +4. IF the test POST request returns a non-200 response or times out within 5 seconds, THEN THE Notification_Settings SHALL reject the Webhook_URL and display a descriptive error message to the User. +5. WHEN a Webhook_URL is saved, THE Notification_Settings SHALL display a confirmation to the User that Slack notifications are enabled. +6. THE Notification_Settings SHALL allow the User to remove a previously saved Webhook_URL, disabling Slack notifications. + +--- + +### Requirement 2: Renewal Reminder Delivery + +**User Story:** As a User, I want to receive renewal reminder notifications in Slack, so that I can act on upcoming subscription renewals without leaving my workspace. + +#### Acceptance Criteria + +1. WHEN the Reminder_Engine triggers a renewal reminder and the User has a saved Webhook_URL, THE Slack_Service SHALL deliver a Renewal_Reminder message to the Webhook_URL. +2. THE Slack_Service SHALL format Renewal_Reminder messages as Block_Kit_Messages containing the subscription name, days until renewal, and renewal cost. +3. THE Slack_Service SHALL include a "Renew Now" button, a "View Details" button, and a "Snooze" button in each Renewal_Reminder Block_Kit_Message. +4. WHEN the Reminder_Engine triggers a renewal reminder and the User does not have a saved Webhook_URL, THE Reminder_Engine SHALL skip Slack delivery and proceed with other configured notification channels. + +--- + +### Requirement 3: Risk Alert Delivery + +**User Story:** As a User, I want to receive risk alerts in Slack, so that I can respond quickly to time-sensitive subscription risk conditions. + +#### Acceptance Criteria + +1. WHEN the Risk_Alert_Engine triggers a risk alert and the User has a saved Webhook_URL, THE Slack_Service SHALL deliver a Risk_Alert message to the Webhook_URL. +2. THE Slack_Service SHALL format Risk_Alert messages as Block_Kit_Messages containing the subscription name, a description of the risk condition, and the time remaining before the risk condition escalates. +3. THE Slack_Service SHALL include a "Renew Approval" button and a "View Dashboard" button in each Risk_Alert Block_Kit_Message. +4. WHEN the Risk_Alert_Engine triggers a risk alert and the User does not have a saved Webhook_URL, THE Risk_Alert_Engine SHALL skip Slack delivery and proceed with other configured notification channels. + +--- + +### Requirement 4: Delivery Failure Isolation + +**User Story:** As a User, I want Slack delivery failures to be handled gracefully, so that a Slack outage or misconfiguration does not prevent me from receiving notifications through other channels. + +#### Acceptance Criteria + +1. IF the Slack_Service receives a non-200 HTTP response from a Webhook_URL during message delivery, THEN THE Slack_Service SHALL log the failure with the HTTP status code and continue execution without throwing an exception to the caller. +2. IF the Slack_Service does not receive a response from a Webhook_URL within 5 seconds, THEN THE Slack_Service SHALL log a timeout error and continue execution without throwing an exception to the caller. +3. WHILE a Slack delivery attempt is in progress, THE Reminder_Engine SHALL continue processing remaining notification channels independently of the Slack delivery result. +4. WHILE a Slack delivery attempt is in progress, THE Risk_Alert_Engine SHALL continue processing remaining notification channels independently of the Slack delivery result. + +--- + +### Requirement 5: Message Construction + +**User Story:** As a User, I want Slack messages to be clearly formatted and actionable, so that I can understand the alert context and take action without navigating to SYNCRO. + +#### Acceptance Criteria + +1. THE Slack_Service SHALL construct Renewal_Reminder Block_Kit_Messages using the subscription name, the number of days until renewal, and the renewal cost formatted as a currency value. +2. THE Slack_Service SHALL construct Risk_Alert Block_Kit_Messages using the subscription name, the risk condition description, and the time remaining until the risk condition escalates. +3. THE Slack_Service SHALL prefix all outgoing messages with the "[SYNCRO]" identifier. +4. WHEN a Block_Kit_Message is constructed with a missing required field (subscription name, renewal date, or cost for reminders; subscription name or risk description for alerts), THE Slack_Service SHALL return a descriptive error and not attempt delivery. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..ad76333 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,100 @@ +# Contributing to SYNCRO + +## Database Migrations + +SYNCRO uses the [Supabase CLI](https://supabase.com/docs/guides/cli) to manage database migrations. +All migration files live in `supabase/migrations/` and are applied in lexicographic order. + +### Prerequisites + +Install the Supabase CLI: + +```bash +# macOS / Linux (Homebrew) +brew install supabase/tap/supabase + +# Windows (Scoop) +scoop bucket add supabase https://github.com/supabase/scoop-bucket.git +scoop install supabase + +# npm (any platform) +npm install -g supabase +``` + +### Local development setup + +```bash +# 1. Start the local Supabase stack (Postgres + Studio + Auth) +supabase start + +# 2. Apply all pending migrations +npm run db:migrate # from /backend, or: supabase db push + +# 3. Seed local database with test data +supabase db reset # applies migrations + seed.sql automatically +``` + +The local Studio UI is available at http://localhost:54323. + +### Creating a new migration + +Always use the CLI to generate migration files — this ensures the timestamp prefix is correct: + +```bash +# From the repo root +supabase migration new +# e.g. supabase migration new add_notifications_table +``` + +This creates `supabase/migrations/YYYYMMDDHHMMSS_.sql`. +Write your SQL in that file, then apply it locally with `supabase db push`. + +### Migration naming convention + +``` +YYYYMMDDHHMMSS_short_description.sql +``` + +Examples: +- `20240115000000_create_push_subscriptions.sql` +- `20240117000000_add_2fa_tables.sql` + +### Applying migrations + +| Environment | Command | +|-------------|---------| +| Local | `npm run db:migrate` | +| Production | `npm run db:migrate:prod` (requires `PRODUCTION_DB_URL` env var) | +| Reset local | `npm run db:reset` | + +### Rollback strategy + +Supabase does not support automatic down migrations. For each migration that makes +destructive changes, document the manual rollback steps in a comment block at the +top of the migration file: + +```sql +-- ROLLBACK: +-- ALTER TABLE public.example DROP COLUMN IF EXISTS new_column; +``` + +For non-destructive migrations (adding tables, indexes, columns with defaults), +the rollback is simply dropping the added object. + +### CI validation + +Every pull request that touches `supabase/migrations/` triggers the +`.github/workflows/database.yml` workflow, which: + +1. Starts a fresh local Supabase stack +2. Applies all migrations from scratch (`supabase db push`) +3. Runs `supabase db lint` to catch SQL issues + +A PR cannot be merged if this workflow fails. + +### Seed data + +`supabase/seed.sql` contains fake data for local development only. +It is applied automatically by `supabase db reset`. + +**Never add real emails, payment data, or any PII to seed.sql.** diff --git a/backend/package.json b/backend/package.json index 5dafc3e..8f1cd30 100644 --- a/backend/package.json +++ b/backend/package.json @@ -11,7 +11,12 @@ "start": "node dist/index.js", "dev": "ts-node-dev --respawn --transpile-only src/index.ts", "validate-env": "node scripts/validate-env.js", - "test": "jest" + "test": "jest", + "swagger:export": "ts-node scripts/export-swagger.ts", + "db:migrate": "supabase db push", + "db:migrate:prod": "supabase db push --db-url \"$PRODUCTION_DB_URL\"", + "db:reset": "supabase db reset", + "db:new": "supabase migration new" }, "dependencies": { "@supabase/supabase-js": "^2.47.10", diff --git a/backend/scripts/export-swagger.ts b/backend/scripts/export-swagger.ts new file mode 100644 index 0000000..7e69b0e --- /dev/null +++ b/backend/scripts/export-swagger.ts @@ -0,0 +1,11 @@ +/** + * Exports the OpenAPI spec to openapi.json + * Run with: npx ts-node scripts/export-swagger.ts + */ +import * as fs from 'fs'; +import * as path from 'path'; +import { swaggerSpec } from '../src/swagger'; + +const outputPath = path.join(__dirname, '..', 'openapi.json'); +fs.writeFileSync(outputPath, JSON.stringify(swaggerSpec, null, 2)); +console.log(`OpenAPI spec exported to ${outputPath}`); diff --git a/backend/src/index.ts b/backend/src/index.ts index 28eeddf..6befbc4 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -1,6 +1,7 @@ import express from 'express'; import cookieParser from 'cookie-parser'; import dotenv from 'dotenv'; +import swaggerUi from 'swagger-ui-express'; // Load environment variables before importing other modules dotenv.config(); @@ -15,10 +16,14 @@ import simulationRoutes from './routes/simulation'; import merchantRoutes from './routes/merchants'; import teamRoutes from './routes/team'; import auditRoutes from './routes/audit'; +import digestRoutes from './routes/digest'; +import mfaRoutes from './routes/mfa'; +import pushNotificationRoutes from './routes/push-notifications'; import { monitoringService } from './services/monitoring-service'; import { healthService } from './services/health-service'; import { eventListener } from './services/event-listener'; import { expiryService } from './services/expiry-service'; +import { swaggerSpec } from './swagger'; const app = express(); const PORT = process.env.PORT || 3001; @@ -55,6 +60,13 @@ app.get('/health', (req, res) => { res.json({ status: 'ok', timestamp: new Date().toISOString() }); }); +// Swagger UI — available in all environments +app.use('/api/docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec)); +app.get('/api/docs.json', (_req, res) => { + res.setHeader('Content-Type', 'application/json'); + res.send(swaggerSpec); +}); + // API Routes app.use('/api/subscriptions', subscriptionRoutes); app.use('/api/risk-score', riskScoreRoutes); @@ -62,14 +74,41 @@ app.use('/api/simulation', simulationRoutes); app.use('/api/merchants', merchantRoutes); app.use('/api/team', teamRoutes); app.use('/api/audit', auditRoutes); +app.use('/api/digest', digestRoutes); +app.use('/api/mfa', mfaRoutes); +app.use('/api/notifications/push', pushNotificationRoutes); // API Routes (Public/Standard) +/** + * @openapi + * /api/reminders/status: + * get: + * tags: [Reminders] + * summary: Get reminder scheduler status + * responses: + * 200: + * description: Scheduler status object + */ app.get('/api/reminders/status', (req, res) => { const status = schedulerService.getStatus(); res.json(status); }); // Admin Monitoring Endpoints (Read-only) +/** + * @openapi + * /api/admin/metrics/subscriptions: + * get: + * tags: [Admin] + * summary: Get subscription metrics + * security: + * - adminKey: [] + * responses: + * 200: + * description: Subscription metrics + * 401: + * description: Unauthorized + */ app.get('/api/admin/metrics/subscriptions', adminAuth, async (req, res) => { try { const metrics = await monitoringService.getSubscriptionMetrics(); @@ -79,6 +118,20 @@ app.get('/api/admin/metrics/subscriptions', adminAuth, async (req, res) => { } }); +/** + * @openapi + * /api/admin/metrics/renewals: + * get: + * tags: [Admin] + * summary: Get renewal metrics + * security: + * - adminKey: [] + * responses: + * 200: + * description: Renewal metrics + * 401: + * description: Unauthorized + */ app.get('/api/admin/metrics/renewals', adminAuth, async (req, res) => { try { const metrics = await monitoringService.getRenewalMetrics(); @@ -88,6 +141,20 @@ app.get('/api/admin/metrics/renewals', adminAuth, async (req, res) => { } }); +/** + * @openapi + * /api/admin/metrics/activity: + * get: + * tags: [Admin] + * summary: Get agent activity metrics + * security: + * - adminKey: [] + * responses: + * 200: + * description: Agent activity + * 401: + * description: Unauthorized + */ app.get('/api/admin/metrics/activity', adminAuth, async (req, res) => { try { const metrics = await monitoringService.getAgentActivity(); @@ -97,7 +164,26 @@ app.get('/api/admin/metrics/activity', adminAuth, async (req, res) => { } }); -// Protocol Health Monitor: unified admin health (metrics, alerts, history) +/** + * @openapi + * /api/admin/health: + * get: + * tags: [Admin] + * summary: Get unified admin health status + * security: + * - adminKey: [] + * parameters: + * - in: query + * name: history + * schema: { type: boolean, default: true } + * responses: + * 200: + * description: Healthy + * 401: + * description: Unauthorized + * 503: + * description: Unhealthy + */ app.get('/api/admin/health', adminAuth, async (req, res) => { try { const includeHistory = req.query.history !== 'false'; @@ -110,7 +196,20 @@ app.get('/api/admin/health', adminAuth, async (req, res) => { } }); -// Manual trigger endpoints (for testing/admin - Should eventually be protected) +/** + * @openapi + * /api/reminders/process: + * post: + * tags: [Reminders] + * summary: Manually process reminders (admin) + * security: + * - adminKey: [] + * responses: + * 200: + * description: Reminders processed + * 401: + * description: Unauthorized + */ app.post('/api/reminders/process', adminAuth, async (req, res) => { try { await reminderEngine.processReminders(); @@ -124,6 +223,30 @@ app.post('/api/reminders/process', adminAuth, async (req, res) => { } }); +/** + * @openapi + * /api/reminders/schedule: + * post: + * tags: [Reminders] + * summary: Schedule reminders (admin) + * security: + * - adminKey: [] + * requestBody: + * content: + * application/json: + * schema: + * type: object + * properties: + * daysBefore: + * type: array + * items: { type: integer } + * default: [7, 3, 1] + * responses: + * 200: + * description: Reminders scheduled + * 401: + * description: Unauthorized + */ app.post('/api/reminders/schedule', adminAuth, async (req, res) => { try { const daysBefore = req.body.daysBefore || [7, 3, 1]; @@ -138,6 +261,20 @@ app.post('/api/reminders/schedule', adminAuth, async (req, res) => { } }); +/** + * @openapi + * /api/reminders/retry: + * post: + * tags: [Reminders] + * summary: Process reminder retries (admin) + * security: + * - adminKey: [] + * responses: + * 200: + * description: Retries processed + * 401: + * description: Unauthorized + */ app.post('/api/reminders/retry', adminAuth, async (req, res) => { try { await reminderEngine.processRetries(); @@ -161,6 +298,20 @@ function startHealthSnapshotInterval() { setTimeout(() => healthService.recordSnapshot().catch(() => {}), 5000); } +/** + * @openapi + * /api/admin/expiry/process: + * post: + * tags: [Admin] + * summary: Manually process subscription expiries (admin) + * security: + * - adminKey: [] + * responses: + * 200: + * description: Expiries processed + * 401: + * description: Unauthorized + */ app.post('/api/admin/expiry/process', adminAuth, async (req, res) => { try { const result = await expiryService.processExpiries(); diff --git a/backend/src/routes/audit.ts b/backend/src/routes/audit.ts index 6cf0be3..a0789ef 100644 --- a/backend/src/routes/audit.ts +++ b/backend/src/routes/audit.ts @@ -6,9 +6,75 @@ import logger from '../config/logger'; const router = Router(); /** - * POST /api/audit - * Accept batch of audit events from client - * Expects: { events: AuditEntry[] } + * @openapi + * /api/audit: + * post: + * tags: [Audit] + * summary: Submit a batch of audit events + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [events] + * properties: + * events: + * type: array + * maxItems: 100 + * items: + * type: object + * properties: + * action: { type: string } + * resourceType: { type: string } + * resourceId: { type: string } + * userId: { type: string } + * metadata: { type: object } + * responses: + * 201: + * description: Events inserted + * content: + * application/json: + * schema: + * type: object + * properties: + * success: { type: boolean } + * inserted: { type: integer } + * failed: { type: integer } + * 400: + * description: Validation error + * get: + * tags: [Audit] + * summary: Retrieve audit logs (admin only) + * security: + * - adminKey: [] + * parameters: + * - in: query + * name: action + * schema: { type: string } + * - in: query + * name: resourceType + * schema: { type: string } + * - in: query + * name: userId + * schema: { type: string } + * - in: query + * name: limit + * schema: { type: integer, default: 100, maximum: 1000 } + * - in: query + * name: offset + * schema: { type: integer, default: 0 } + * - in: query + * name: startDate + * schema: { type: string, format: date-time } + * - in: query + * name: endDate + * schema: { type: string, format: date-time } + * responses: + * 200: + * description: Audit logs with pagination + * 401: + * description: Unauthorized */ router.post('/', async (req: Request, res: Response) => { try { diff --git a/backend/src/routes/digest.ts b/backend/src/routes/digest.ts index 5b5fd84..33a6524 100644 --- a/backend/src/routes/digest.ts +++ b/backend/src/routes/digest.ts @@ -12,8 +12,46 @@ const router = Router(); router.use(authenticate); /** - * GET /api/digest/preferences - * Fetch the authenticated user's digest settings. + * @openapi + * /api/digest/preferences: + * get: + * tags: [Digest] + * summary: Get digest preferences + * security: + * - bearerAuth: [] + * responses: + * 200: + * description: Digest preferences + * content: + * application/json: + * schema: + * type: object + * properties: + * success: { type: boolean } + * data: { $ref: '#/components/schemas/DigestPreferences' } + * 401: + * description: Unauthorized + * patch: + * tags: [Digest] + * summary: Update digest preferences + * security: + * - bearerAuth: [] + * requestBody: + * content: + * application/json: + * schema: + * type: object + * properties: + * digestEnabled: { type: boolean } + * digestDay: { type: integer, minimum: 1, maximum: 28 } + * includeYearToDate: { type: boolean } + * responses: + * 200: + * description: Updated preferences + * 400: + * description: Validation error + * 401: + * description: Unauthorized */ router.get('/preferences', async (req: AuthenticatedRequest, res: Response) => { try { @@ -65,9 +103,20 @@ router.patch('/preferences', async (req: AuthenticatedRequest, res: Response) => }); /** - * POST /api/digest/test - * Immediately send a digest preview to the authenticated user. - * Rate-limited: one test email per hour (tracked via audit log). + * @openapi + * /api/digest/test: + * post: + * tags: [Digest] + * summary: Send a test digest email (rate-limited to 1/hour) + * security: + * - bearerAuth: [] + * responses: + * 200: + * description: Test digest sent + * 401: + * description: Unauthorized + * 429: + * description: Rate limit — already sent within the last hour */ router.post('/test', async (req: AuthenticatedRequest, res: Response) => { try { @@ -102,8 +151,18 @@ router.post('/test', async (req: AuthenticatedRequest, res: Response) => { }); /** - * GET /api/digest/history - * Return the last 24 digest send records for the user. + * @openapi + * /api/digest/history: + * get: + * tags: [Digest] + * summary: Get digest send history (last 24 records) + * security: + * - bearerAuth: [] + * responses: + * 200: + * description: Digest history + * 401: + * description: Unauthorized */ router.get('/history', async (req: AuthenticatedRequest, res: Response) => { try { @@ -121,9 +180,18 @@ router.get('/history', async (req: AuthenticatedRequest, res: Response) => { // ─── Admin routes ───────────────────────────────────────────────────────────── /** - * POST /api/digest/admin/run - * Manually trigger the monthly digest run for all opted-in users. - * Admin only. + * @openapi + * /api/digest/admin/run: + * post: + * tags: [Digest] + * summary: Manually trigger monthly digest run (admin only) + * security: + * - adminKey: [] + * responses: + * 200: + * description: Digest run result + * 401: + * description: Unauthorized */ router.post('/admin/run', adminAuth, async (_req, res: Response) => { try { diff --git a/backend/src/routes/merchants.ts b/backend/src/routes/merchants.ts index cb13b10..78304af 100644 --- a/backend/src/routes/merchants.ts +++ b/backend/src/routes/merchants.ts @@ -7,8 +7,34 @@ import { renewalRateLimiter } from '../middleware/rate-limiter'; // Added Import const router = Router(); /** - * GET /api/merchants - * List merchants with optional filtering + * @openapi + * /api/merchants: + * get: + * tags: [Merchants] + * summary: List merchants + * parameters: + * - in: query + * name: category + * schema: { type: string } + * - in: query + * name: limit + * schema: { type: integer, default: 20 } + * - in: query + * name: offset + * schema: { type: integer, default: 0 } + * responses: + * 200: + * description: List of merchants + * content: + * application/json: + * schema: + * type: object + * properties: + * success: { type: boolean } + * data: + * type: array + * items: { $ref: '#/components/schemas/Merchant' } + * pagination: { $ref: '#/components/schemas/Pagination' } */ router.get('/', async (req: Request, res: Response) => { try { @@ -39,8 +65,28 @@ router.get('/', async (req: Request, res: Response) => { }); /** - * GET /api/merchants/:id - * Get single merchant by ID + * @openapi + * /api/merchants/{id}: + * get: + * tags: [Merchants] + * summary: Get a merchant by ID + * parameters: + * - in: path + * name: id + * required: true + * schema: { type: string, format: uuid } + * responses: + * 200: + * description: Merchant object + * content: + * application/json: + * schema: + * type: object + * properties: + * success: { type: boolean } + * data: { $ref: '#/components/schemas/Merchant' } + * 404: + * description: Not found */ router.get('/:id', async (req: Request, res: Response) => { try { @@ -61,8 +107,32 @@ router.get('/:id', async (req: Request, res: Response) => { }); /** - * POST /api/merchants - * Create new merchant (Admin only) + * @openapi + * /api/merchants: + * post: + * tags: [Merchants] + * summary: Create a merchant (admin only) + * security: + * - adminKey: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [name] + * properties: + * name: { type: string } + * category: { type: string } + * website_url: { type: string, format: uri } + * logo_url: { type: string, format: uri } + * responses: + * 201: + * description: Merchant created + * 400: + * description: Validation error + * 401: + * description: Unauthorized */ router.post('/', adminAuth, async (req: Request, res: Response) => { try { @@ -90,9 +160,50 @@ router.post('/', adminAuth, async (req: Request, res: Response) => { }); /** - * PATCH /api/merchants/:id - * Update merchant (Admin only) - * NOTE: Rate limiter applied here to prevent mass renewal/update congestion per merchant. + * @openapi + * /api/merchants/{id}: + * patch: + * tags: [Merchants] + * summary: Update a merchant (admin only) + * security: + * - adminKey: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: { type: string, format: uuid } + * requestBody: + * content: + * application/json: + * schema: + * type: object + * properties: + * name: { type: string } + * category: { type: string } + * website_url: { type: string, format: uri } + * logo_url: { type: string, format: uri } + * responses: + * 200: + * description: Updated merchant + * 401: + * description: Unauthorized + * 404: + * description: Not found + * delete: + * tags: [Merchants] + * summary: Delete a merchant (admin only) + * security: + * - adminKey: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: { type: string, format: uuid } + * responses: + * 200: + * description: Deleted + * 401: + * description: Unauthorized */ router.patch('/:id', adminAuth, renewalRateLimiter, async (req: Request, res: Response) => { try { @@ -113,8 +224,7 @@ router.patch('/:id', adminAuth, renewalRateLimiter, async (req: Request, res: Re }); /** - * DELETE /api/merchants/:id - * Delete merchant (Admin only) + * DELETE /api/merchants/:id — covered by PATCH doc block above */ router.delete('/:id', adminAuth, async (req: Request, res: Response) => { try { diff --git a/backend/src/routes/mfa.ts b/backend/src/routes/mfa.ts index 6f63d57..90a72ee 100644 --- a/backend/src/routes/mfa.ts +++ b/backend/src/routes/mfa.ts @@ -12,10 +12,32 @@ const totpRateLimiter = new TotpRateLimiter(); // Apply authenticate middleware to all routes router.use(authenticate); -// --------------------------------------------------------------------------- -// POST /api/2fa/recovery-codes/generate -// Generate 10 recovery codes for the authenticated user -// --------------------------------------------------------------------------- +/** + * @openapi + * /api/2fa/recovery-codes/generate: + * post: + * tags: [2FA] + * summary: Generate 10 recovery codes + * security: + * - bearerAuth: [] + * responses: + * 201: + * description: Recovery codes generated + * content: + * application/json: + * schema: + * type: object + * properties: + * success: { type: boolean } + * data: + * type: object + * properties: + * codes: + * type: array + * items: { type: string } + * 401: + * description: Unauthorized + */ router.post('/2fa/recovery-codes/generate', async (req: AuthenticatedRequest, res: Response) => { try { const userId = req.user!.id; @@ -30,10 +52,33 @@ router.post('/2fa/recovery-codes/generate', async (req: AuthenticatedRequest, re } }); -// --------------------------------------------------------------------------- -// POST /api/2fa/recovery-codes/verify -// Verify a recovery code — rate-limited per session -// --------------------------------------------------------------------------- +/** + * @openapi + * /api/2fa/recovery-codes/verify: + * post: + * tags: [2FA] + * summary: Verify a recovery code (rate-limited) + * security: + * - bearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [code] + * properties: + * code: { type: string } + * responses: + * 200: + * description: Code valid + * 400: + * description: code is required + * 401: + * description: Invalid or already-used recovery code + * 429: + * description: Too many failed attempts + */ router.post('/2fa/recovery-codes/verify', async (req: AuthenticatedRequest, res: Response) => { const sessionId = req.user!.id; @@ -78,10 +123,20 @@ router.post('/2fa/recovery-codes/verify', async (req: AuthenticatedRequest, res: } }); -// --------------------------------------------------------------------------- -// DELETE /api/2fa/recovery-codes -// Invalidate all recovery codes for the authenticated user -// --------------------------------------------------------------------------- +/** + * @openapi + * /api/2fa/recovery-codes: + * delete: + * tags: [2FA] + * summary: Invalidate all recovery codes + * security: + * - bearerAuth: [] + * responses: + * 200: + * description: All codes invalidated + * 401: + * description: Unauthorized + */ router.delete('/2fa/recovery-codes', async (req: AuthenticatedRequest, res: Response) => { try { await recoveryCodeService.invalidateAll(req.user!.id); @@ -95,10 +150,31 @@ router.delete('/2fa/recovery-codes', async (req: AuthenticatedRequest, res: Resp } }); -// --------------------------------------------------------------------------- -// POST /api/2fa/notify -// Send a 2FA lifecycle confirmation email (non-blocking on failure) -// --------------------------------------------------------------------------- +/** + * @openapi + * /api/2fa/notify: + * post: + * tags: [2FA] + * summary: Send a 2FA lifecycle notification email + * security: + * - bearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [event] + * properties: + * event: { type: string, enum: [enrolled, disabled] } + * responses: + * 200: + * description: Notification queued + * 400: + * description: Invalid event value + * 401: + * description: Unauthorized + */ router.post('/2fa/notify', async (req: AuthenticatedRequest, res: Response) => { const { event } = req.body as { event?: 'enrolled' | 'disabled' }; @@ -125,10 +201,40 @@ router.post('/2fa/notify', async (req: AuthenticatedRequest, res: Response) => { res.json({ success: true }); }); -// --------------------------------------------------------------------------- -// PUT /api/teams/:teamId/require-2fa -// Set the team's 2FA enforcement policy (team owner only) -// --------------------------------------------------------------------------- +/** + * @openapi + * /api/teams/{teamId}/require-2fa: + * put: + * tags: [2FA] + * summary: Set team 2FA enforcement policy (owner only) + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: teamId + * required: true + * schema: { type: string, format: uuid } + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [required] + * properties: + * required: { type: boolean } + * responses: + * 200: + * description: Policy updated + * 400: + * description: required must be boolean + * 401: + * description: Unauthorized + * 403: + * description: Only team owner can change this + * 404: + * description: Team not found + */ router.put('/teams/:teamId/require-2fa', async (req: AuthenticatedRequest, res: Response) => { const { teamId } = req.params; const { required } = req.body as { required?: boolean }; diff --git a/backend/src/routes/push-notifications.ts b/backend/src/routes/push-notifications.ts index df6c20c..c0bf05e 100644 --- a/backend/src/routes/push-notifications.ts +++ b/backend/src/routes/push-notifications.ts @@ -8,9 +8,48 @@ const router = Router(); router.use(authenticate); /** - * POST /api/notifications/push/subscribe - * Save a browser push subscription for the authenticated user. - * If the same endpoint already exists for this user, the keys are updated (upsert). + * @openapi + * /api/notifications/push/subscribe: + * post: + * tags: [Push Notifications] + * summary: Save a browser push subscription + * security: + * - bearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [endpoint, keys] + * properties: + * endpoint: { type: string, format: uri } + * keys: + * type: object + * required: [p256dh, auth] + * properties: + * p256dh: { type: string } + * auth: { type: string } + * userAgent: { type: string } + * responses: + * 201: + * description: Subscription saved + * content: + * application/json: + * schema: + * type: object + * properties: + * success: { type: boolean } + * data: + * type: object + * properties: + * id: { type: string } + * endpoint: { type: string } + * createdAt: { type: string, format: date-time } + * 400: + * description: Missing or invalid fields + * 401: + * description: Unauthorized */ router.post('/subscribe', async (req: AuthenticatedRequest, res: Response) => { try { @@ -69,8 +108,25 @@ router.post('/subscribe', async (req: AuthenticatedRequest, res: Response) => { }); /** - * DELETE /api/notifications/push/unsubscribe - * Remove a push subscription by endpoint (or all subscriptions if no endpoint given). + * @openapi + * /api/notifications/push/unsubscribe: + * delete: + * tags: [Push Notifications] + * summary: Remove a push subscription + * security: + * - bearerAuth: [] + * requestBody: + * content: + * application/json: + * schema: + * type: object + * properties: + * endpoint: { type: string, description: "Omit to remove all subscriptions" } + * responses: + * 200: + * description: Removed + * 401: + * description: Unauthorized */ router.delete('/unsubscribe', async (req: AuthenticatedRequest, res: Response) => { try { @@ -103,8 +159,29 @@ router.delete('/unsubscribe', async (req: AuthenticatedRequest, res: Response) = }); /** - * GET /api/notifications/push/status - * Returns whether the current user has an active push subscription. + * @openapi + * /api/notifications/push/status: + * get: + * tags: [Push Notifications] + * summary: Check if user has an active push subscription + * security: + * - bearerAuth: [] + * responses: + * 200: + * description: Subscription status + * content: + * application/json: + * schema: + * type: object + * properties: + * success: { type: boolean } + * data: + * type: object + * properties: + * subscribed: { type: boolean } + * count: { type: integer } + * 401: + * description: Unauthorized */ router.get('/status', async (req: AuthenticatedRequest, res: Response) => { try { diff --git a/backend/src/routes/risk-score.ts b/backend/src/routes/risk-score.ts index eeca0c8..48d91b3 100644 --- a/backend/src/routes/risk-score.ts +++ b/backend/src/routes/risk-score.ts @@ -14,8 +14,32 @@ const router = express.Router(); router.use(authenticate); /** - * GET /api/risk-score/:subscriptionId - * Get risk score for a specific subscription + * @openapi + * /api/risk-score/{subscriptionId}: + * get: + * tags: [Risk Score] + * summary: Get risk score for a subscription + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: subscriptionId + * required: true + * schema: { type: string, format: uuid } + * responses: + * 200: + * description: Risk score data + * content: + * application/json: + * schema: + * type: object + * properties: + * success: { type: boolean } + * data: { $ref: '#/components/schemas/RiskScore' } + * 401: + * description: Unauthorized + * 404: + * description: Risk score not found */ router.get('/:subscriptionId', async (req: AuthenticatedRequest, res: Response) => { try { @@ -59,8 +83,28 @@ router.get('/:subscriptionId', async (req: AuthenticatedRequest, res: Response) }); /** - * GET /api/risk-score - * Get all risk scores for authenticated user + * @openapi + * /api/risk-score: + * get: + * tags: [Risk Score] + * summary: Get all risk scores for the authenticated user + * security: + * - bearerAuth: [] + * responses: + * 200: + * description: Array of risk scores + * content: + * application/json: + * schema: + * type: object + * properties: + * success: { type: boolean } + * data: + * type: array + * items: { $ref: '#/components/schemas/RiskScore' } + * total: { type: integer } + * 401: + * description: Unauthorized */ router.get('/', async (req: AuthenticatedRequest, res: Response) => { try { @@ -96,9 +140,18 @@ router.get('/', async (req: AuthenticatedRequest, res: Response) => { }); /** - * POST /api/risk-score/recalculate - * Manually trigger risk recalculation for all subscriptions - * Note: In production, this should be admin-only + * @openapi + * /api/risk-score/recalculate: + * post: + * tags: [Risk Score] + * summary: Trigger risk recalculation for all subscriptions + * security: + * - bearerAuth: [] + * responses: + * 200: + * description: Recalculation result + * 401: + * description: Unauthorized */ router.post('/recalculate', async (req: AuthenticatedRequest, res: Response) => { try { @@ -133,8 +186,32 @@ router.post('/recalculate', async (req: AuthenticatedRequest, res: Response) => }); /** - * POST /api/risk-score/:subscriptionId/calculate - * Calculate risk for a specific subscription + * @openapi + * /api/risk-score/{subscriptionId}/calculate: + * post: + * tags: [Risk Score] + * summary: Calculate risk for a specific subscription + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: subscriptionId + * required: true + * schema: { type: string, format: uuid } + * responses: + * 200: + * description: Calculated risk score + * content: + * application/json: + * schema: + * type: object + * properties: + * success: { type: boolean } + * data: { $ref: '#/components/schemas/RiskScore' } + * 401: + * description: Unauthorized + * 404: + * description: Subscription not found */ router.post('/:subscriptionId/calculate', async (req: AuthenticatedRequest, res: Response) => { try { diff --git a/backend/src/routes/simulation.ts b/backend/src/routes/simulation.ts index 9bd4caf..e8a78a4 100644 --- a/backend/src/routes/simulation.ts +++ b/backend/src/routes/simulation.ts @@ -9,12 +9,37 @@ const router = Router(); router.use(authenticate); /** - * GET /api/simulation - * Generate billing simulation for the authenticated user - * - * Query Parameters: - * - days (optional): Number of days to project (1-365, default: 30) - * - balance (optional): Current balance for risk assessment + * @openapi + * /api/simulation: + * get: + * tags: [Simulation] + * summary: Generate a billing simulation + * description: Projects upcoming billing charges for the authenticated user. + * security: + * - bearerAuth: [] + * parameters: + * - in: query + * name: days + * schema: { type: integer, minimum: 1, maximum: 365, default: 30 } + * description: Number of days to project + * - in: query + * name: balance + * schema: { type: number } + * description: Current balance for risk assessment + * responses: + * 200: + * description: Simulation result + * content: + * application/json: + * schema: + * type: object + * properties: + * success: { type: boolean } + * data: { type: object } + * 400: + * description: Invalid query parameters + * 401: + * description: Unauthorized */ router.get('/', async (req: AuthenticatedRequest, res: Response) => { try { diff --git a/backend/src/routes/subscriptions.ts b/backend/src/routes/subscriptions.ts index c749492..86dd059 100644 --- a/backend/src/routes/subscriptions.ts +++ b/backend/src/routes/subscriptions.ts @@ -47,8 +47,45 @@ const router = Router(); router.use(authenticate); /** - * GET /api/subscriptions - * List user's subscriptions with optional filtering + * @openapi + * /api/subscriptions: + * get: + * tags: [Subscriptions] + * summary: List subscriptions + * description: Returns all subscriptions for the authenticated user with optional filtering. + * security: + * - bearerAuth: [] + * parameters: + * - in: query + * name: status + * schema: { type: string, enum: [active, cancelled, expired] } + * - in: query + * name: category + * schema: { type: string } + * - in: query + * name: limit + * schema: { type: integer, default: 20 } + * - in: query + * name: offset + * schema: { type: integer, default: 0 } + * responses: + * 200: + * description: List of subscriptions + * content: + * application/json: + * schema: + * type: object + * properties: + * success: { type: boolean } + * data: + * type: array + * items: { $ref: '#/components/schemas/Subscription' } + * pagination: { $ref: '#/components/schemas/Pagination' } + * 401: + * description: Unauthorized + * content: + * application/json: + * schema: { $ref: '#/components/schemas/ErrorResponse' } */ router.get("/", async (req: AuthenticatedRequest, res: Response) => { try { @@ -81,8 +118,32 @@ router.get("/", async (req: AuthenticatedRequest, res: Response) => { }); /** - * GET /api/subscriptions/:id - * Get single subscription by ID + * @openapi + * /api/subscriptions/{id}: + * get: + * tags: [Subscriptions] + * summary: Get a subscription + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: { type: string, format: uuid } + * responses: + * 200: + * description: Subscription object + * content: + * application/json: + * schema: + * type: object + * properties: + * success: { type: boolean } + * data: { $ref: '#/components/schemas/Subscription' } + * 401: + * description: Unauthorized + * 404: + * description: Not found */ router.get("/:id", validateSubscriptionOwnership, async (req: AuthenticatedRequest, res: Response) => { try { @@ -110,8 +171,49 @@ router.get("/:id", validateSubscriptionOwnership, async (req: AuthenticatedReque }); /** - * POST /api/subscriptions - * Create new subscription with idempotency support + * @openapi + * /api/subscriptions: + * post: + * tags: [Subscriptions] + * summary: Create a subscription + * security: + * - bearerAuth: [] + * parameters: + * - in: header + * name: Idempotency-Key + * schema: { type: string } + * description: Optional key to prevent duplicate submissions + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [name, price, billing_cycle] + * properties: + * name: { type: string, example: Netflix } + * price: { type: number, example: 15.99 } + * billing_cycle: { type: string, enum: [monthly, yearly, quarterly] } + * renewal_url: { type: string, format: uri } + * website_url: { type: string, format: uri } + * logo_url: { type: string, format: uri } + * responses: + * 201: + * description: Subscription created + * content: + * application/json: + * schema: + * type: object + * properties: + * success: { type: boolean } + * data: { $ref: '#/components/schemas/Subscription' } + * blockchain: { $ref: '#/components/schemas/BlockchainResult' } + * 207: + * description: Created but blockchain sync failed + * 400: + * description: Validation error + * 401: + * description: Unauthorized */ router.post("/", async (req: AuthenticatedRequest, res: Response) => { try { @@ -200,8 +302,54 @@ router.post("/", async (req: AuthenticatedRequest, res: Response) => { }); /** - * PATCH /api/subscriptions/:id - * Update subscription with optimistic locking + * @openapi + * /api/subscriptions/{id}: + * patch: + * tags: [Subscriptions] + * summary: Update a subscription + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: { type: string, format: uuid } + * - in: header + * name: Idempotency-Key + * schema: { type: string } + * - in: header + * name: If-Match + * schema: { type: string } + * description: Expected version for optimistic locking + * requestBody: + * content: + * application/json: + * schema: + * type: object + * properties: + * name: { type: string } + * price: { type: number } + * billing_cycle: { type: string, enum: [monthly, yearly, quarterly] } + * renewal_url: { type: string, format: uri } + * website_url: { type: string, format: uri } + * logo_url: { type: string, format: uri } + * responses: + * 200: + * description: Updated subscription + * content: + * application/json: + * schema: + * type: object + * properties: + * success: { type: boolean } + * data: { $ref: '#/components/schemas/Subscription' } + * blockchain: { $ref: '#/components/schemas/BlockchainResult' } + * 207: + * description: Updated but blockchain sync failed + * 401: + * description: Unauthorized + * 404: + * description: Not found */ router.patch("/:id", validateSubscriptionOwnership, async (req: AuthenticatedRequest, res: Response) => { try { @@ -280,8 +428,27 @@ router.patch("/:id", validateSubscriptionOwnership, async (req: AuthenticatedReq }); /** - * DELETE /api/subscriptions/:id - * Delete subscription + * @openapi + * /api/subscriptions/{id}: + * delete: + * tags: [Subscriptions] + * summary: Delete a subscription + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: { type: string, format: uuid } + * responses: + * 200: + * description: Deleted + * 207: + * description: Deleted but blockchain sync failed + * 401: + * description: Unauthorized + * 404: + * description: Not found */ router.delete("/:id", validateSubscriptionOwnership, async (req: AuthenticatedRequest, res: Response) => { try { @@ -320,8 +487,37 @@ router.delete("/:id", validateSubscriptionOwnership, async (req: AuthenticatedRe }); /** - * POST /api/subscriptions/:id/attach-gift-card - * Attach gift card info to a subscription + * @openapi + * /api/subscriptions/{id}/attach-gift-card: + * post: + * tags: [Subscriptions] + * summary: Attach a gift card to a subscription + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: { type: string, format: uuid } + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [giftCardHash, provider] + * properties: + * giftCardHash: { type: string } + * provider: { type: string } + * responses: + * 201: + * description: Gift card attached + * 400: + * description: Validation error + * 401: + * description: Unauthorized + * 404: + * description: Subscription not found */ router.post('/:id/attach-gift-card', validateSubscriptionOwnership, async (req: AuthenticatedRequest, res: Response) => { try { @@ -371,9 +567,33 @@ router.post('/:id/attach-gift-card', validateSubscriptionOwnership, async (req: }); /** - * POST /api/subscriptions/:id/retry-sync - * Retry blockchain sync for a subscription - * Enforces cooldown period to prevent rapid repeated attempts + * @openapi + * /api/subscriptions/{id}/retry-sync: + * post: + * tags: [Subscriptions] + * summary: Retry blockchain sync for a subscription + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: { type: string, format: uuid } + * responses: + * 200: + * description: Sync result + * 401: + * description: Unauthorized + * 429: + * description: Cooldown period active + * content: + * application/json: + * schema: + * type: object + * properties: + * success: { type: boolean } + * error: { type: string } + * retryAfter: { type: integer, description: Seconds to wait } */ router.post("/:id/retry-sync", validateSubscriptionOwnership, async (req: AuthenticatedRequest, res: Response) => { try { @@ -409,8 +629,33 @@ router.post("/:id/retry-sync", validateSubscriptionOwnership, async (req: Authen }); /** - * GET /api/subscriptions/:id/cooldown-status - * Check if a subscription can be retried or if cooldown is active + * @openapi + * /api/subscriptions/{id}/cooldown-status: + * get: + * tags: [Subscriptions] + * summary: Check retry cooldown status + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: { type: string, format: uuid } + * responses: + * 200: + * description: Cooldown status + * content: + * application/json: + * schema: + * type: object + * properties: + * success: { type: boolean } + * canRetry: { type: boolean } + * isOnCooldown: { type: boolean } + * timeRemainingSeconds: { type: integer, nullable: true } + * message: { type: string } + * 401: + * description: Unauthorized */ router.get("/:id/cooldown-status", validateSubscriptionOwnership, async (req: AuthenticatedRequest, res: Response) => { try { @@ -441,8 +686,30 @@ function extractWaitTime(message: string): number { } /** - * POST /api/subscriptions/:id/cancel - * Cancel subscription with blockchain sync + * @openapi + * /api/subscriptions/{id}/cancel: + * post: + * tags: [Subscriptions] + * summary: Cancel a subscription + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: { type: string, format: uuid } + * - in: header + * name: Idempotency-Key + * schema: { type: string } + * responses: + * 200: + * description: Cancelled + * 207: + * description: Cancelled but blockchain sync failed + * 401: + * description: Unauthorized + * 404: + * description: Not found */ router.post("/:id/cancel", validateSubscriptionOwnership, async (req: AuthenticatedRequest, res: Response) => { try { @@ -509,8 +776,35 @@ router.post("/:id/cancel", validateSubscriptionOwnership, async (req: Authentica }); /** - * POST /api/subscriptions/bulk - * Bulk operations (delete, update status, etc.) + * @openapi + * /api/subscriptions/bulk: + * post: + * tags: [Subscriptions] + * summary: Bulk operations on subscriptions + * security: + * - bearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [operation, ids] + * properties: + * operation: { type: string, enum: [delete, update] } + * ids: + * type: array + * items: { type: string, format: uuid } + * data: + * type: object + * description: Required when operation is "update" + * responses: + * 200: + * description: Bulk operation results + * 400: + * description: Validation error + * 401: + * description: Unauthorized */ router.post("/bulk", validateBulkSubscriptionOwnership, async (req: AuthenticatedRequest, res: Response) => { try { diff --git a/backend/src/routes/team.ts b/backend/src/routes/team.ts index e9521b5..de35cb3 100644 --- a/backend/src/routes/team.ts +++ b/backend/src/routes/team.ts @@ -56,6 +56,29 @@ function canManageTeam(ctx: { isOwner: boolean; memberRole: string | null }): bo // --------------------------------------------------------------------------- // GET /api/team — list team members // --------------------------------------------------------------------------- +/** + * @openapi + * /api/team: + * get: + * tags: [Team] + * summary: List team members + * security: + * - bearerAuth: [] + * responses: + * 200: + * description: Array of team members + * content: + * application/json: + * schema: + * type: object + * properties: + * success: { type: boolean } + * data: + * type: array + * items: { $ref: '#/components/schemas/TeamMember' } + * 401: + * description: Unauthorized + */ router.get('/', async (req: AuthenticatedRequest, res: Response) => { try { const ctx = await resolveUserTeam(req.user!.id); @@ -100,6 +123,36 @@ router.get('/', async (req: AuthenticatedRequest, res: Response) => { // --------------------------------------------------------------------------- // POST /api/team/invite — invite a new member // --------------------------------------------------------------------------- +/** + * @openapi + * /api/team/invite: + * post: + * tags: [Team] + * summary: Invite a team member + * security: + * - bearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [email] + * properties: + * email: { type: string, format: email } + * role: { type: string, enum: [admin, member, viewer], default: member } + * responses: + * 201: + * description: Invitation sent + * 400: + * description: Validation error + * 401: + * description: Unauthorized + * 403: + * description: Forbidden — only owners/admins can invite + * 409: + * description: Pending invitation already exists or user already a member + */ router.post('/invite', async (req: AuthenticatedRequest, res: Response) => { try { const { email, role = 'member' } = req.body as { email?: string; role?: string }; @@ -218,6 +271,22 @@ router.post('/invite', async (req: AuthenticatedRequest, res: Response) => { // --------------------------------------------------------------------------- // GET /api/team/pending — list pending invitations // --------------------------------------------------------------------------- +/** + * @openapi + * /api/team/pending: + * get: + * tags: [Team] + * summary: List pending invitations + * security: + * - bearerAuth: [] + * responses: + * 200: + * description: Pending invitations + * 401: + * description: Unauthorized + * 403: + * description: Forbidden + */ router.get('/pending', async (req: AuthenticatedRequest, res: Response) => { try { const ctx = await resolveUserTeam(req.user!.id); @@ -253,6 +322,29 @@ router.get('/pending', async (req: AuthenticatedRequest, res: Response) => { // --------------------------------------------------------------------------- // POST /api/team/accept/:token — accept an invitation // --------------------------------------------------------------------------- +/** + * @openapi + * /api/team/accept/{token}: + * post: + * tags: [Team] + * summary: Accept a team invitation + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: token + * required: true + * schema: { type: string } + * responses: + * 200: + * description: Joined team + * 403: + * description: Email mismatch + * 404: + * description: Invitation not found or already used + * 410: + * description: Invitation expired + */ router.post('/accept/:token', async (req: AuthenticatedRequest, res: Response) => { try { const { token } = req.params; @@ -323,6 +415,40 @@ router.post('/accept/:token', async (req: AuthenticatedRequest, res: Response) = // --------------------------------------------------------------------------- // PUT /api/team/:memberId/role — update a member's role (owner only) // --------------------------------------------------------------------------- +/** + * @openapi + * /api/team/{memberId}/role: + * put: + * tags: [Team] + * summary: Update a member's role (owner only) + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: memberId + * required: true + * schema: { type: string, format: uuid } + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [role] + * properties: + * role: { type: string, enum: [admin, member, viewer] } + * responses: + * 200: + * description: Role updated + * 400: + * description: Invalid role + * 401: + * description: Unauthorized + * 403: + * description: Only owner can change roles + * 404: + * description: Member not found + */ router.put('/:memberId/role', async (req: AuthenticatedRequest, res: Response) => { try { const { memberId } = req.params; @@ -373,6 +499,31 @@ router.put('/:memberId/role', async (req: AuthenticatedRequest, res: Response) = // --------------------------------------------------------------------------- // DELETE /api/team/:memberId — remove a team member (owner or admin) // --------------------------------------------------------------------------- +/** + * @openapi + * /api/team/{memberId}: + * delete: + * tags: [Team] + * summary: Remove a team member + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: memberId + * required: true + * schema: { type: string, format: uuid } + * responses: + * 200: + * description: Member removed + * 400: + * description: Cannot remove owner + * 401: + * description: Unauthorized + * 403: + * description: Forbidden + * 404: + * description: Member not found + */ router.delete('/:memberId', async (req: AuthenticatedRequest, res: Response) => { try { const { memberId } = req.params; diff --git a/backend/src/swagger.ts b/backend/src/swagger.ts new file mode 100644 index 0000000..d3b370c --- /dev/null +++ b/backend/src/swagger.ts @@ -0,0 +1,125 @@ +import swaggerJSDoc from 'swagger-jsdoc'; + +const options: swaggerJSDoc.Options = { + definition: { + openapi: '3.0.0', + info: { + title: 'SYNCRO API', + version: '1.0.0', + description: 'Self-custodial subscription management platform API', + }, + servers: [ + { url: 'http://localhost:3001', description: 'Development server' }, + ], + components: { + securitySchemes: { + bearerAuth: { + type: 'http', + scheme: 'bearer', + bearerFormat: 'JWT', + description: 'Supabase JWT token via Authorization: Bearer ', + }, + cookieAuth: { + type: 'apiKey', + in: 'cookie', + name: 'authToken', + description: 'HTTP-only cookie auth (alternative to Bearer)', + }, + adminKey: { + type: 'apiKey', + in: 'header', + name: 'X-Admin-API-Key', + description: 'Admin API key for protected admin endpoints', + }, + }, + schemas: { + SuccessResponse: { + type: 'object', + properties: { + success: { type: 'boolean', example: true }, + }, + }, + ErrorResponse: { + type: 'object', + properties: { + success: { type: 'boolean', example: false }, + error: { type: 'string' }, + }, + }, + Pagination: { + type: 'object', + properties: { + total: { type: 'integer' }, + limit: { type: 'integer' }, + offset: { type: 'integer' }, + }, + }, + Subscription: { + type: 'object', + properties: { + id: { type: 'string', format: 'uuid' }, + user_id: { type: 'string', format: 'uuid' }, + name: { type: 'string', example: 'Netflix' }, + price: { type: 'number', example: 15.99 }, + billing_cycle: { type: 'string', enum: ['monthly', 'yearly', 'quarterly'] }, + status: { type: 'string', enum: ['active', 'cancelled', 'expired'] }, + renewal_url: { type: 'string', format: 'uri', nullable: true }, + website_url: { type: 'string', format: 'uri', nullable: true }, + logo_url: { type: 'string', format: 'uri', nullable: true }, + created_at: { type: 'string', format: 'date-time' }, + updated_at: { type: 'string', format: 'date-time' }, + }, + }, + BlockchainResult: { + type: 'object', + properties: { + synced: { type: 'boolean' }, + transactionHash: { type: 'string', nullable: true }, + error: { type: 'string', nullable: true }, + }, + }, + RiskScore: { + type: 'object', + properties: { + subscription_id: { type: 'string', format: 'uuid' }, + risk_level: { type: 'string', enum: ['low', 'medium', 'high', 'critical'] }, + risk_factors: { type: 'array', items: { type: 'object' } }, + last_calculated_at: { type: 'string', format: 'date-time' }, + }, + }, + Merchant: { + type: 'object', + properties: { + id: { type: 'string', format: 'uuid' }, + name: { type: 'string' }, + category: { type: 'string', nullable: true }, + website_url: { type: 'string', format: 'uri', nullable: true }, + logo_url: { type: 'string', format: 'uri', nullable: true }, + created_at: { type: 'string', format: 'date-time' }, + }, + }, + TeamMember: { + type: 'object', + properties: { + id: { type: 'string', format: 'uuid' }, + userId: { type: 'string', format: 'uuid' }, + email: { type: 'string', format: 'email', nullable: true }, + role: { type: 'string', enum: ['admin', 'member', 'viewer'] }, + joinedAt: { type: 'string', format: 'date-time' }, + }, + }, + DigestPreferences: { + type: 'object', + properties: { + digestEnabled: { type: 'boolean' }, + digestDay: { type: 'integer', minimum: 1, maximum: 28 }, + includeYearToDate: { type: 'boolean' }, + }, + }, + }, + }, + }, + apis: ['./src/routes/**/*.ts', './src/index.ts'], +}; + +export const swaggerSpec = swaggerJSDoc(options); diff --git a/supabase/config.toml b/supabase/config.toml new file mode 100644 index 0000000..a050713 --- /dev/null +++ b/supabase/config.toml @@ -0,0 +1,35 @@ +# Supabase local development configuration +# https://supabase.com/docs/guides/cli/config + +[api] +port = 54321 +schemas = ["public", "graphql_public"] +extra_search_path = ["public", "extensions"] +max_rows = 1000 + +[db] +port = 54322 +shadow_port = 54320 +major_version = 15 + +[studio] +port = 54323 + +[inbucket] +port = 54324 +smtp_port = 54325 +pop3_port = 54326 + +[storage] +file_size_limit = "50MiB" + +[auth] +site_url = "http://localhost:3000" +additional_redirect_urls = ["https://localhost:3000"] +jwt_expiry = 3600 +enable_signup = true + +[auth.email] +enable_signup = true +double_confirm_changes = true +enable_confirmations = false diff --git a/supabase/migrations/20240101000000_create_audit_logs.sql b/supabase/migrations/20240101000000_create_audit_logs.sql new file mode 100644 index 0000000..ebf0569 --- /dev/null +++ b/supabase/migrations/20240101000000_create_audit_logs.sql @@ -0,0 +1,36 @@ +-- Audit logs table for tracking user actions, security events, and system changes +CREATE TABLE IF NOT EXISTS public.audit_logs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID REFERENCES auth.users(id) ON DELETE SET NULL, + action TEXT NOT NULL, + resource_type TEXT NOT NULL, + resource_id TEXT, + metadata JSONB, + ip_address INET, + user_agent TEXT, + created_at TIMESTAMPTZ DEFAULT NOW() +); + +-- Indexes for optimal query performance +CREATE INDEX IF NOT EXISTS idx_audit_logs_user_created ON public.audit_logs(user_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_audit_logs_resource ON public.audit_logs(resource_type, resource_id); +CREATE INDEX IF NOT EXISTS idx_audit_logs_action ON public.audit_logs(action, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_audit_logs_created ON public.audit_logs(created_at DESC); + +-- Enable RLS (Row Level Security) for audit logs +ALTER TABLE public.audit_logs ENABLE ROW LEVEL SECURITY; + +-- Audit logs can only be read by users who own them or by admins +CREATE POLICY audit_logs_select_own ON public.audit_logs + FOR SELECT + USING (auth.uid() = user_id OR auth.jwt() ->> 'is_admin' = 'true'); + +-- Only the backend (with service role) can insert audit logs +CREATE POLICY audit_logs_insert_backend ON public.audit_logs + FOR INSERT + WITH CHECK (true); + +-- Audit logs are immutable (no updates or deletes except by admin) +CREATE POLICY audit_logs_delete_admin ON public.audit_logs + FOR DELETE + USING (auth.jwt() ->> 'is_admin' = 'true'); diff --git a/supabase/migrations/20240102000000_create_renewal_tables.sql b/supabase/migrations/20240102000000_create_renewal_tables.sql new file mode 100644 index 0000000..551dde8 --- /dev/null +++ b/supabase/migrations/20240102000000_create_renewal_tables.sql @@ -0,0 +1,30 @@ +-- Create renewal_logs table for tracking renewal execution +CREATE TABLE IF NOT EXISTS renewal_logs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + subscription_id UUID NOT NULL REFERENCES subscriptions(id) ON DELETE CASCADE, + user_id UUID NOT NULL, + status TEXT NOT NULL CHECK (status IN ('success', 'failed')), + transaction_hash TEXT, + failure_reason TEXT, + error_message TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Create renewal_approvals table for tracking approvals +CREATE TABLE IF NOT EXISTS renewal_approvals ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + subscription_id UUID NOT NULL REFERENCES subscriptions(id) ON DELETE CASCADE, + approval_id TEXT NOT NULL, + max_spend NUMERIC, + expires_at TIMESTAMPTZ, + used BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(subscription_id, approval_id) +); + +-- Create indexes +CREATE INDEX idx_renewal_logs_subscription ON renewal_logs(subscription_id); +CREATE INDEX idx_renewal_logs_user ON renewal_logs(user_id); +CREATE INDEX idx_renewal_logs_status ON renewal_logs(status); +CREATE INDEX idx_renewal_approvals_subscription ON renewal_approvals(subscription_id); +CREATE INDEX idx_renewal_approvals_used ON renewal_approvals(used); diff --git a/supabase/migrations/20240103000000_create_team_invitations.sql b/supabase/migrations/20240103000000_create_team_invitations.sql new file mode 100644 index 0000000..41cf899 --- /dev/null +++ b/supabase/migrations/20240103000000_create_team_invitations.sql @@ -0,0 +1,17 @@ +-- Team invitations table for pending member invites +create table if not exists public.team_invitations ( + id uuid primary key default gen_random_uuid(), + team_id uuid not null references public.teams(id) on delete cascade, + email text not null, + role text not null default 'member' check (role in ('admin', 'member', 'viewer')), + token uuid not null unique default gen_random_uuid(), + invited_by uuid not null references auth.users(id) on delete cascade, + expires_at timestamp with time zone not null default (now() + interval '7 days'), + accepted_at timestamp with time zone, + created_at timestamp with time zone default now() +); + +-- Indexes +create index if not exists team_invitations_token_idx on public.team_invitations(token); +create index if not exists team_invitations_team_id_idx on public.team_invitations(team_id); +create index if not exists team_invitations_email_idx on public.team_invitations(email); diff --git a/supabase/migrations/20240107000000_create_reminder_tables.sql b/supabase/migrations/20240107000000_create_reminder_tables.sql new file mode 100644 index 0000000..16ecd07 --- /dev/null +++ b/supabase/migrations/20240107000000_create_reminder_tables.sql @@ -0,0 +1,106 @@ +-- Create reminder_schedules table to track scheduled reminders +create table if not exists public.reminder_schedules ( + id uuid primary key default gen_random_uuid(), + subscription_id uuid not null references public.subscriptions(id) on delete cascade, + user_id uuid not null references auth.users(id) on delete cascade, + reminder_date date not null, + reminder_type text not null check (reminder_type in ('renewal', 'trial_expiry', 'cancellation')), + days_before integer not null, + status text not null default 'pending' check (status in ('pending', 'sent', 'failed', 'cancelled')), + created_at timestamp with time zone default timezone('utc'::text, now()) not null, + updated_at timestamp with time zone default timezone('utc'::text, now()) not null +); + +-- Enable RLS +alter table public.reminder_schedules enable row level security; + +-- RLS Policies +create policy "reminder_schedules_select_own" + on public.reminder_schedules for select + using (auth.uid() = user_id); + +create policy "reminder_schedules_insert_own" + on public.reminder_schedules for insert + with check (auth.uid() = user_id); + +create policy "reminder_schedules_update_own" + on public.reminder_schedules for update + using (auth.uid() = user_id); + +-- Indexes +create index if not exists reminder_schedules_user_id_idx on public.reminder_schedules(user_id); +create index if not exists reminder_schedules_subscription_id_idx on public.reminder_schedules(subscription_id); +create index if not exists reminder_schedules_reminder_date_idx on public.reminder_schedules(reminder_date); +create index if not exists reminder_schedules_status_idx on public.reminder_schedules(status); + +-- Create notification_deliveries table to track delivery attempts +create table if not exists public.notification_deliveries ( + id uuid primary key default gen_random_uuid(), + reminder_schedule_id uuid not null references public.reminder_schedules(id) on delete cascade, + user_id uuid not null references auth.users(id) on delete cascade, + channel text not null check (channel in ('email', 'push')), + status text not null check (status in ('pending', 'sent', 'failed', 'retrying')), + attempt_count integer default 0, + max_attempts integer default 3, + last_attempt_at timestamp with time zone, + next_retry_at timestamp with time zone, + error_message text, + metadata jsonb, + created_at timestamp with time zone default timezone('utc'::text, now()) not null, + updated_at timestamp with time zone default timezone('utc'::text, now()) not null +); + +-- Enable RLS +alter table public.notification_deliveries enable row level security; + +-- RLS Policies +create policy "notification_deliveries_select_own" + on public.notification_deliveries for select + using (auth.uid() = user_id); + +create policy "notification_deliveries_insert_own" + on public.notification_deliveries for insert + with check (auth.uid() = user_id); + +create policy "notification_deliveries_update_own" + on public.notification_deliveries for update + using (auth.uid() = user_id); + +-- Indexes +create index if not exists notification_deliveries_reminder_schedule_id_idx on public.notification_deliveries(reminder_schedule_id); +create index if not exists notification_deliveries_user_id_idx on public.notification_deliveries(user_id); +create index if not exists notification_deliveries_status_idx on public.notification_deliveries(status); +create index if not exists notification_deliveries_next_retry_at_idx on public.notification_deliveries(next_retry_at); + +-- Create blockchain_logs table to track on-chain events +create table if not exists public.blockchain_logs ( + id uuid primary key default gen_random_uuid(), + user_id uuid not null references auth.users(id) on delete cascade, + event_type text not null, + event_data jsonb not null, + transaction_hash text, + block_number text, + status text not null default 'pending' check (status in ('pending', 'confirmed', 'failed')), + error_message text, + created_at timestamp with time zone default timezone('utc'::text, now()) not null, + updated_at timestamp with time zone default timezone('utc'::text, now()) not null +); + +-- Enable RLS +alter table public.blockchain_logs enable row level security; + +-- RLS Policies +create policy "blockchain_logs_select_own" + on public.blockchain_logs for select + using (auth.uid() = user_id); + +create policy "blockchain_logs_insert_own" + on public.blockchain_logs for insert + with check (auth.uid() = user_id); + +-- Indexes +create index if not exists blockchain_logs_user_id_idx on public.blockchain_logs(user_id); +create index if not exists blockchain_logs_event_type_idx on public.blockchain_logs(event_type); +create index if not exists blockchain_logs_status_idx on public.blockchain_logs(status); +create index if not exists blockchain_logs_transaction_hash_idx on public.blockchain_logs(transaction_hash); + diff --git a/supabase/migrations/20240108000000_create_idempotency_table.sql b/supabase/migrations/20240108000000_create_idempotency_table.sql new file mode 100644 index 0000000..e2e51fa --- /dev/null +++ b/supabase/migrations/20240108000000_create_idempotency_table.sql @@ -0,0 +1,35 @@ +-- Create idempotency_keys table for request deduplication +create table if not exists public.idempotency_keys ( + id uuid primary key default gen_random_uuid(), + key text not null, + user_id uuid not null references auth.users(id) on delete cascade, + request_hash text not null, + response_status integer not null, + response_body jsonb not null, + created_at timestamp with time zone default now(), + expires_at timestamp with time zone not null +); + +-- Enable RLS +alter table public.idempotency_keys enable row level security; + +-- RLS Policies for idempotency_keys +create policy "idempotency_keys_select_own" + on public.idempotency_keys for select + using (auth.uid() = user_id); + +create policy "idempotency_keys_insert_own" + on public.idempotency_keys for insert + with check (auth.uid() = user_id); + +-- Unique constraint on key + user_id + request_hash +create unique index if not exists idempotency_keys_unique_idx + on public.idempotency_keys(key, user_id, request_hash); + +-- Index for cleanup queries +create index if not exists idempotency_keys_expires_at_idx + on public.idempotency_keys(expires_at); + +-- Index for user lookups +create index if not exists idempotency_keys_user_id_idx + on public.idempotency_keys(user_id); diff --git a/supabase/migrations/20240108010000_create_user_preferences.sql b/supabase/migrations/20240108010000_create_user_preferences.sql new file mode 100644 index 0000000..194091f --- /dev/null +++ b/supabase/migrations/20240108010000_create_user_preferences.sql @@ -0,0 +1,45 @@ +-- Create user_preferences table +create table if not exists public.user_preferences ( + user_id uuid primary key references auth.users(id) on delete cascade, + notification_channels text[] not null default '{"email"}', + reminder_timing integer[] not null default '{7, 3, 1}', + email_opt_ins jsonb not null default '{"marketing": false, "reminders": true, "updates": true}', + automation_flags jsonb not null default '{"auto_renew": false, "auto_retry": true}', + created_at timestamp with time zone default timezone('utc'::text, now()) not null, + updated_at timestamp with time zone default timezone('utc'::text, now()) not null +); + +-- Enable RLS +alter table public.user_preferences enable row level security; + +-- RLS Policies +create policy "user_preferences_select_own" + on public.user_preferences for select + using (auth.uid() = user_id); + +create policy "user_preferences_insert_own" + on public.user_preferences for insert + with check (auth.uid() = user_id); + +create policy "user_preferences_update_own" + on public.user_preferences for update + using (auth.uid() = user_id); + +-- Trigger for updated_at +create or replace function public.handle_updated_at() +returns trigger as $$ +begin + new.updated_at = now(); + return new; +end; +$$ language plpgsql; + +create trigger set_updated_at + before update on public.user_preferences + for each row + execute function public.handle_updated_at(); + +-- Add default preferences for existing users (optional, but good for backward compatibility) +insert into public.user_preferences (user_id) +select id from auth.users +on conflict (user_id) do nothing; diff --git a/supabase/migrations/20240109000000_create_event_tables.sql b/supabase/migrations/20240109000000_create_event_tables.sql new file mode 100644 index 0000000..8b3dba1 --- /dev/null +++ b/supabase/migrations/20240109000000_create_event_tables.sql @@ -0,0 +1,47 @@ +-- Contract events table +CREATE TABLE IF NOT EXISTS contract_events ( + id BIGSERIAL PRIMARY KEY, + sub_id BIGINT NOT NULL, + event_type VARCHAR(50) NOT NULL, + ledger INTEGER NOT NULL, + tx_hash VARCHAR(128) NOT NULL, + event_data JSONB NOT NULL, + processed_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + UNIQUE(tx_hash, event_type, sub_id) +); + +CREATE INDEX idx_contract_events_sub_id ON contract_events(sub_id); +CREATE INDEX idx_contract_events_ledger ON contract_events(ledger); +CREATE INDEX idx_contract_events_type ON contract_events(event_type); + +-- Event cursor for tracking last processed ledger +CREATE TABLE IF NOT EXISTS event_cursor ( + id INTEGER PRIMARY KEY DEFAULT 1, + last_ledger INTEGER NOT NULL DEFAULT 0, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + CONSTRAINT single_cursor CHECK (id = 1) +); + +-- Renewal approvals table +CREATE TABLE IF NOT EXISTS renewal_approvals ( + id BIGSERIAL PRIMARY KEY, + blockchain_sub_id BIGINT NOT NULL, + approval_id BIGINT NOT NULL, + max_spend BIGINT NOT NULL, + expires_at INTEGER NOT NULL, + used BOOLEAN DEFAULT FALSE, + rejected BOOLEAN DEFAULT FALSE, + rejection_reason INTEGER, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + UNIQUE(blockchain_sub_id, approval_id) +); + +CREATE INDEX idx_renewal_approvals_sub_id ON renewal_approvals(blockchain_sub_id); + +-- Add blockchain_sub_id to subscriptions if not exists +ALTER TABLE subscriptions +ADD COLUMN IF NOT EXISTS blockchain_sub_id BIGINT, +ADD COLUMN IF NOT EXISTS failure_count INTEGER DEFAULT 0, +ADD COLUMN IF NOT EXISTS executor_address VARCHAR(56); + +CREATE INDEX IF NOT EXISTS idx_subscriptions_blockchain_sub_id ON subscriptions(blockchain_sub_id); diff --git a/supabase/migrations/20240110000000_add_expiry_columns.sql b/supabase/migrations/20240110000000_add_expiry_columns.sql new file mode 100644 index 0000000..86158a3 --- /dev/null +++ b/supabase/migrations/20240110000000_add_expiry_columns.sql @@ -0,0 +1,15 @@ +-- Add expiry columns to subscriptions table +ALTER TABLE public.subscriptions + ADD COLUMN IF NOT EXISTS expiry_threshold integer DEFAULT NULL, + ADD COLUMN IF NOT EXISTS expired_at timestamptz DEFAULT NULL; + +-- Update status CHECK to include 'expired' +ALTER TABLE public.subscriptions DROP CONSTRAINT IF EXISTS subscriptions_status_check; +ALTER TABLE public.subscriptions + ADD CONSTRAINT subscriptions_status_check + CHECK (status IN ('active', 'cancelled', 'paused', 'trial', 'expired')); + +-- Partial index for the daily expiry cron query +CREATE INDEX IF NOT EXISTS idx_subscriptions_expiry_candidates + ON public.subscriptions (last_used_at, created_at) + WHERE status = 'active' AND expiry_threshold IS NOT NULL; diff --git a/supabase/migrations/20240110010000_add_renewal_cycle_id.sql b/supabase/migrations/20240110010000_add_renewal_cycle_id.sql new file mode 100644 index 0000000..0651e96 --- /dev/null +++ b/supabase/migrations/20240110010000_add_renewal_cycle_id.sql @@ -0,0 +1,2 @@ +ALTER TABLE subscriptions +ADD COLUMN IF NOT EXISTS last_renewal_cycle_id BIGINT DEFAULT NULL; diff --git a/supabase/migrations/20240110020000_create_health_metrics_table.sql b/supabase/migrations/20240110020000_create_health_metrics_table.sql new file mode 100644 index 0000000..6f77215 --- /dev/null +++ b/supabase/migrations/20240110020000_create_health_metrics_table.sql @@ -0,0 +1,27 @@ +-- Protocol Health Monitor: historical metrics for renewal system observability +-- Stores snapshots for failed renewals/hour, contract errors, agent activity + +CREATE TABLE IF NOT EXISTS health_metrics_snapshots ( + id BIGSERIAL PRIMARY KEY, + recorded_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + + -- Renewal health (last rolling hour) + failed_renewals_last_hour INTEGER NOT NULL DEFAULT 0, + successful_deliveries_last_hour INTEGER NOT NULL DEFAULT 0, + + -- Contract/blockchain errors (last rolling hour) + contract_errors_last_hour INTEGER NOT NULL DEFAULT 0, + blockchain_failed_last_hour INTEGER NOT NULL DEFAULT 0, + + -- Agent activity + last_agent_activity_at TIMESTAMP WITH TIME ZONE, + pending_reminders INTEGER NOT NULL DEFAULT 0, + processed_reminders_last_24h INTEGER NOT NULL DEFAULT 0, + + -- Alert state at snapshot time (denormalized for history) + alerts_triggered JSONB DEFAULT '[]'::jsonb +); + +CREATE INDEX idx_health_metrics_recorded_at ON health_metrics_snapshots(recorded_at DESC); + +COMMENT ON TABLE health_metrics_snapshots IS 'Historical protocol health metrics for monitoring and alerting'; diff --git a/supabase/migrations/20240110030000_create_risk_detection_tables.sql b/supabase/migrations/20240110030000_create_risk_detection_tables.sql new file mode 100644 index 0000000..28662a9 --- /dev/null +++ b/supabase/migrations/20240110030000_create_risk_detection_tables.sql @@ -0,0 +1,141 @@ +-- Create subscription risk detection tables +-- This migration adds tables for tracking subscription risk scores, renewal attempts, and approvals + +-- Risk scores table +CREATE TABLE IF NOT EXISTS public.subscription_risk_scores ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + subscription_id UUID NOT NULL REFERENCES public.subscriptions(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + risk_level TEXT NOT NULL CHECK (risk_level IN ('LOW', 'MEDIUM', 'HIGH')), + risk_factors JSONB NOT NULL DEFAULT '[]'::jsonb, + last_calculated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + last_notified_risk_level TEXT CHECK (last_notified_risk_level IN ('LOW', 'MEDIUM', 'HIGH')), + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + UNIQUE(subscription_id) +); + +-- Renewal attempts table +CREATE TABLE IF NOT EXISTS public.subscription_renewal_attempts ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + subscription_id UUID NOT NULL REFERENCES public.subscriptions(id) ON DELETE CASCADE, + attempt_date TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + success BOOLEAN NOT NULL, + error_message TEXT, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- Subscription approvals table +CREATE TABLE IF NOT EXISTS public.subscription_approvals ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + subscription_id UUID NOT NULL REFERENCES public.subscriptions(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + approval_type TEXT NOT NULL CHECK (approval_type IN ('renewal', 'payment')), + expires_at TIMESTAMP WITH TIME ZONE NOT NULL, + status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'expired', 'revoked')), + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- Enable RLS +ALTER TABLE public.subscription_risk_scores ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.subscription_renewal_attempts ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.subscription_approvals ENABLE ROW LEVEL SECURITY; + +-- RLS Policies for subscription_risk_scores +CREATE POLICY "risk_scores_select_own" + ON public.subscription_risk_scores FOR SELECT + USING (auth.uid() = user_id); + +CREATE POLICY "risk_scores_insert_own" + ON public.subscription_risk_scores FOR INSERT + WITH CHECK (auth.uid() = user_id); + +CREATE POLICY "risk_scores_update_own" + ON public.subscription_risk_scores FOR UPDATE + USING (auth.uid() = user_id); + +CREATE POLICY "risk_scores_delete_own" + ON public.subscription_risk_scores FOR DELETE + USING (auth.uid() = user_id); + +-- RLS Policies for subscription_renewal_attempts +CREATE POLICY "renewal_attempts_select_own" + ON public.subscription_renewal_attempts FOR SELECT + USING ( + EXISTS ( + SELECT 1 FROM public.subscriptions + WHERE subscriptions.id = subscription_renewal_attempts.subscription_id + AND subscriptions.user_id = auth.uid() + ) + ); + +CREATE POLICY "renewal_attempts_insert_own" + ON public.subscription_renewal_attempts FOR INSERT + WITH CHECK ( + EXISTS ( + SELECT 1 FROM public.subscriptions + WHERE subscriptions.id = subscription_renewal_attempts.subscription_id + AND subscriptions.user_id = auth.uid() + ) + ); + +-- RLS Policies for subscription_approvals +CREATE POLICY "approvals_select_own" + ON public.subscription_approvals FOR SELECT + USING (auth.uid() = user_id); + +CREATE POLICY "approvals_insert_own" + ON public.subscription_approvals FOR INSERT + WITH CHECK (auth.uid() = user_id); + +CREATE POLICY "approvals_update_own" + ON public.subscription_approvals FOR UPDATE + USING (auth.uid() = user_id); + +CREATE POLICY "approvals_delete_own" + ON public.subscription_approvals FOR DELETE + USING (auth.uid() = user_id); + +-- Indexes for performance +CREATE INDEX IF NOT EXISTS idx_risk_scores_subscription ON public.subscription_risk_scores(subscription_id); +CREATE INDEX IF NOT EXISTS idx_risk_scores_user ON public.subscription_risk_scores(user_id); +CREATE INDEX IF NOT EXISTS idx_risk_scores_level ON public.subscription_risk_scores(risk_level); +CREATE INDEX IF NOT EXISTS idx_risk_scores_calculated ON public.subscription_risk_scores(last_calculated_at DESC); + +CREATE INDEX IF NOT EXISTS idx_renewal_attempts_subscription ON public.subscription_renewal_attempts(subscription_id); +CREATE INDEX IF NOT EXISTS idx_renewal_attempts_date ON public.subscription_renewal_attempts(attempt_date DESC); +CREATE INDEX IF NOT EXISTS idx_renewal_attempts_success ON public.subscription_renewal_attempts(success); + +CREATE INDEX IF NOT EXISTS idx_approvals_subscription ON public.subscription_approvals(subscription_id); +CREATE INDEX IF NOT EXISTS idx_approvals_user ON public.subscription_approvals(user_id); +CREATE INDEX IF NOT EXISTS idx_approvals_expires ON public.subscription_approvals(expires_at); +CREATE INDEX IF NOT EXISTS idx_approvals_status ON public.subscription_approvals(status); + +-- Function to automatically update updated_at timestamp +CREATE OR REPLACE FUNCTION update_updated_at_column() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- Triggers for updated_at +CREATE TRIGGER update_risk_scores_updated_at + BEFORE UPDATE ON public.subscription_risk_scores + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); + +CREATE TRIGGER update_approvals_updated_at + BEFORE UPDATE ON public.subscription_approvals + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); + +-- Comments for documentation +COMMENT ON TABLE public.subscription_risk_scores IS 'Stores computed risk levels for subscriptions'; +COMMENT ON TABLE public.subscription_renewal_attempts IS 'Tracks renewal payment attempts for risk calculation'; +COMMENT ON TABLE public.subscription_approvals IS 'Manages approval requirements and expiration for subscriptions'; + +COMMENT ON COLUMN public.subscription_risk_scores.risk_factors IS 'JSON array of risk factors with type, weight, and details'; +COMMENT ON COLUMN public.subscription_risk_scores.last_notified_risk_level IS 'Last risk level that triggered a notification (for deduplication)'; diff --git a/supabase/migrations/20240110040000_create_subscription_gift_cards.sql b/supabase/migrations/20240110040000_create_subscription_gift_cards.sql new file mode 100644 index 0000000..50d0570 --- /dev/null +++ b/supabase/migrations/20240110040000_create_subscription_gift_cards.sql @@ -0,0 +1,34 @@ +-- Create subscription_gift_cards table for gift card attachments +CREATE TABLE IF NOT EXISTS public.subscription_gift_cards ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + subscription_id uuid NOT NULL REFERENCES public.subscriptions(id) ON DELETE CASCADE, + user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + gift_card_hash text NOT NULL, + provider text NOT NULL, + transaction_hash text, + status text NOT NULL DEFAULT 'attached' CHECK (status IN ('attached', 'redeemed', 'expired')), + created_at timestamp with time zone DEFAULT timezone('utc'::text, now()) NOT NULL, + updated_at timestamp with time zone DEFAULT timezone('utc'::text, now()) NOT NULL, + UNIQUE(subscription_id, gift_card_hash) +); + +-- Enable RLS +ALTER TABLE public.subscription_gift_cards ENABLE ROW LEVEL SECURITY; + +-- RLS Policies +CREATE POLICY "subscription_gift_cards_select_own" + ON public.subscription_gift_cards FOR SELECT + USING (auth.uid() = user_id); + +CREATE POLICY "subscription_gift_cards_insert_own" + ON public.subscription_gift_cards FOR INSERT + WITH CHECK (auth.uid() = user_id); + +CREATE POLICY "subscription_gift_cards_update_own" + ON public.subscription_gift_cards FOR UPDATE + USING (auth.uid() = user_id); + +-- Indexes +CREATE INDEX IF NOT EXISTS subscription_gift_cards_subscription_id_idx ON public.subscription_gift_cards(subscription_id); +CREATE INDEX IF NOT EXISTS subscription_gift_cards_user_id_idx ON public.subscription_gift_cards(user_id); +CREATE INDEX IF NOT EXISTS subscription_gift_cards_gift_card_hash_idx ON public.subscription_gift_cards(gift_card_hash); diff --git a/supabase/migrations/20240111000000_rework_expiry.sql b/supabase/migrations/20240111000000_rework_expiry.sql new file mode 100644 index 0000000..2ae3f5b --- /dev/null +++ b/supabase/migrations/20240111000000_rework_expiry.sql @@ -0,0 +1,14 @@ +-- 011: Rework auto-expiry to use env-based per-cycle thresholds +-- Removes per-subscription expiry_threshold column and updates indexes + +ALTER TABLE public.subscriptions DROP COLUMN IF EXISTS expiry_threshold; +DROP INDEX IF EXISTS idx_subscriptions_expiry_candidates; + +-- New index: active subs with non-lifetime billing cycles +CREATE INDEX IF NOT EXISTS idx_subscriptions_expiry_candidates_v2 + ON public.subscriptions (billing_cycle, last_used_at, created_at) + WHERE status = 'active' AND billing_cycle IN ('monthly', 'quarterly', 'yearly'); + +-- GIN index on notifications for warning dedup containment queries +CREATE INDEX IF NOT EXISTS idx_notifications_subscription_data + ON public.notifications USING gin (subscription_data); diff --git a/supabase/migrations/20240112000000_create_renewal_locks.sql b/supabase/migrations/20240112000000_create_renewal_locks.sql new file mode 100644 index 0000000..26b3d54 --- /dev/null +++ b/supabase/migrations/20240112000000_create_renewal_locks.sql @@ -0,0 +1,30 @@ +-- Renewal locks table for preventing concurrent renewal execution +CREATE TABLE IF NOT EXISTS renewal_locks ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + subscription_id UUID NOT NULL REFERENCES subscriptions(id), + cycle_id BIGINT NOT NULL, + locked_at TIMESTAMPTZ NOT NULL DEFAULT now(), + expires_at TIMESTAMPTZ NOT NULL, + lock_holder TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'released', 'expired')) +); + +-- Atomic locking: only one active lock per (subscription_id, cycle_id) at a time +CREATE UNIQUE INDEX IF NOT EXISTS idx_renewal_locks_active_unique + ON renewal_locks (subscription_id, cycle_id) + WHERE status = 'active'; + +-- Efficient cleanup of expired locks +CREATE INDEX IF NOT EXISTS idx_renewal_locks_expires_active + ON renewal_locks (expires_at) + WHERE status = 'active'; + +-- Enable RLS +ALTER TABLE renewal_locks ENABLE ROW LEVEL SECURITY; + +-- Service role only policy +CREATE POLICY "Service role access only" + ON renewal_locks + FOR ALL + USING (auth.role() = 'service_role') + WITH CHECK (auth.role() = 'service_role'); diff --git a/supabase/migrations/20240113000000_add_lifecycle_timestamps.sql b/supabase/migrations/20240113000000_add_lifecycle_timestamps.sql new file mode 100644 index 0000000..efd36c6 --- /dev/null +++ b/supabase/migrations/20240113000000_add_lifecycle_timestamps.sql @@ -0,0 +1,9 @@ +-- 013: Add on-chain lifecycle timestamp columns to subscriptions. +-- These columns mirror immutable timestamps stored on the Soroban contract. +-- Values are Unix epoch seconds (BIGINT) from env.ledger().timestamp(). + +ALTER TABLE public.subscriptions + ADD COLUMN IF NOT EXISTS blockchain_created_at BIGINT DEFAULT NULL, + ADD COLUMN IF NOT EXISTS blockchain_activated_at BIGINT DEFAULT NULL, + ADD COLUMN IF NOT EXISTS blockchain_last_renewed_at BIGINT DEFAULT NULL, + ADD COLUMN IF NOT EXISTS blockchain_canceled_at BIGINT DEFAULT NULL; diff --git a/supabase/migrations/20240114000000_add_renewal_cooldown.sql b/supabase/migrations/20240114000000_add_renewal_cooldown.sql new file mode 100644 index 0000000..6ae22ac --- /dev/null +++ b/supabase/migrations/20240114000000_add_renewal_cooldown.sql @@ -0,0 +1,70 @@ +-- Migration: Add renewal cooldown mechanism to prevent rapid repeated retry attempts +-- This migration adds fields to track the last renewal attempt and enforces cooldown periods + +-- Add cooldown-related columns to subscriptions table +ALTER TABLE public.subscriptions +ADD COLUMN IF NOT EXISTS last_renewal_attempt_at TIMESTAMP WITH TIME ZONE, +ADD COLUMN IF NOT EXISTS renewal_cooldown_minutes INTEGER DEFAULT 5; + +-- Add comment for documentation +COMMENT ON COLUMN public.subscriptions.last_renewal_attempt_at IS 'Timestamp of the last renewal attempt (successful or failed)'; +COMMENT ON COLUMN public.subscriptions.renewal_cooldown_minutes IS 'Minimum minutes to wait between renewal attempts (default: 5 minutes)'; + +-- Update subscription_renewal_attempts table to include attempt type and last_attempt_timestamp for tracking +ALTER TABLE public.subscription_renewal_attempts +ADD COLUMN IF NOT EXISTS attempt_type TEXT DEFAULT 'automatic' CHECK (attempt_type IN ('automatic', 'manual', 'retry')), +ADD COLUMN IF NOT EXISTS updated_subscription_record BOOLEAN DEFAULT FALSE; + +-- Create an index on last_renewal_attempt_at for efficient cooldown checks +CREATE INDEX IF NOT EXISTS idx_subscriptions_last_renewal_attempt +ON public.subscriptions(last_renewal_attempt_at); + +-- Create a function to check if cooldown period is active +CREATE OR REPLACE FUNCTION check_renewal_cooldown( + subscription_id UUID, + cooldown_minutes INTEGER DEFAULT 5 +) +RETURNS TABLE(is_cooldown_active BOOLEAN, time_remaining_seconds INTEGER, can_retry BOOLEAN) AS $$ +BEGIN + RETURN QUERY + SELECT + (NOW() - sub.last_renewal_attempt_at) < (cooldown_minutes || ' minutes')::INTERVAL as is_cooldown_active, + EXTRACT(EPOCH FROM (cooldown_minutes::TEXT || ' minutes')::INTERVAL - (NOW() - sub.last_renewal_attempt_at))::INTEGER as time_remaining_seconds, + (NOW() - sub.last_renewal_attempt_at) >= (cooldown_minutes || ' minutes')::INTERVAL as can_retry + FROM public.subscriptions sub + WHERE sub.id = subscription_id AND sub.last_renewal_attempt_at IS NOT NULL; + + -- If no previous attempt, cooldown is not active + IF NOT FOUND THEN + RETURN QUERY SELECT FALSE, 0, TRUE; + END IF; +END; +$$ LANGUAGE plpgsql; + +-- Create a function to update last renewal attempt timestamp +CREATE OR REPLACE FUNCTION update_last_renewal_attempt( + subscription_id UUID +) +RETURNS TABLE(updated BOOLEAN, previous_attempt_at TIMESTAMP WITH TIME ZONE, new_attempt_at TIMESTAMP WITH TIME ZONE) AS $$ +DECLARE + v_previous_attempt TIMESTAMP WITH TIME ZONE; +BEGIN + SELECT last_renewal_attempt_at INTO v_previous_attempt + FROM public.subscriptions + WHERE id = subscription_id; + + UPDATE public.subscriptions + SET last_renewal_attempt_at = NOW(), + updated_at = NOW() + WHERE id = subscription_id; + + RETURN QUERY SELECT TRUE, v_previous_attempt, NOW(); +END; +$$ LANGUAGE plpgsql; + +-- Add RLS policy to prevent direct updates of renewal tracking fields (only via functions) +ALTER TABLE public.subscriptions ENABLE ROW LEVEL SECURITY; + +-- Grant execute permissions on functions to authenticated users +GRANT EXECUTE ON FUNCTION check_renewal_cooldown(UUID, INTEGER) TO authenticated; +GRANT EXECUTE ON FUNCTION update_last_renewal_attempt(UUID) TO authenticated; diff --git a/supabase/migrations/20240115000000_create_push_subscriptions.sql b/supabase/migrations/20240115000000_create_push_subscriptions.sql new file mode 100644 index 0000000..78f0dfa --- /dev/null +++ b/supabase/migrations/20240115000000_create_push_subscriptions.sql @@ -0,0 +1,58 @@ +-- Migration: Create push_subscriptions table for Web Push notification support +-- This enables the reminder engine to deliver real push notifications to users + +CREATE TABLE IF NOT EXISTS public.push_subscriptions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + endpoint TEXT NOT NULL, + p256dh TEXT NOT NULL, + auth TEXT NOT NULL, + user_agent TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(user_id, endpoint) +); + +-- Enable RLS +ALTER TABLE public.push_subscriptions ENABLE ROW LEVEL SECURITY; + +-- RLS Policies +CREATE POLICY "push_subscriptions_select_own" + ON public.push_subscriptions FOR SELECT + USING (auth.uid() = user_id); + +CREATE POLICY "push_subscriptions_insert_own" + ON public.push_subscriptions FOR INSERT + WITH CHECK (auth.uid() = user_id); + +CREATE POLICY "push_subscriptions_update_own" + ON public.push_subscriptions FOR UPDATE + USING (auth.uid() = user_id); + +CREATE POLICY "push_subscriptions_delete_own" + ON public.push_subscriptions FOR DELETE + USING (auth.uid() = user_id); + +-- Indexes +CREATE INDEX IF NOT EXISTS push_subscriptions_user_id_idx + ON public.push_subscriptions(user_id); + +CREATE INDEX IF NOT EXISTS push_subscriptions_created_at_idx + ON public.push_subscriptions(created_at DESC); + +-- Auto-update updated_at +CREATE OR REPLACE FUNCTION public.handle_push_subscription_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER set_push_subscriptions_updated_at + BEFORE UPDATE ON public.push_subscriptions + FOR EACH ROW + EXECUTE FUNCTION public.handle_push_subscription_updated_at(); + +COMMENT ON TABLE public.push_subscriptions IS + 'Browser Web Push API subscriptions for delivering renewal reminders to users'; \ No newline at end of file diff --git a/supabase/migrations/20240116000000_add_monthly_digest.sql b/supabase/migrations/20240116000000_add_monthly_digest.sql new file mode 100644 index 0000000..d7fc3c6 --- /dev/null +++ b/supabase/migrations/20240116000000_add_monthly_digest.sql @@ -0,0 +1,47 @@ +-- ============================================================ +-- Migration: Monthly digest preferences and audit log +-- ============================================================ + +-- 1. Add digest columns to user_preferences +-- ───────────────────────────────────────────────────────────── +ALTER TABLE public.user_preferences + ADD COLUMN IF NOT EXISTS digest_enabled BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN IF NOT EXISTS digest_day SMALLINT NOT NULL DEFAULT 1 + CHECK (digest_day BETWEEN 1 AND 28), + ADD COLUMN IF NOT EXISTS include_year_to_date BOOLEAN NOT NULL DEFAULT TRUE; + +COMMENT ON COLUMN public.user_preferences.digest_enabled + IS 'Whether the user receives the monthly digest email'; +COMMENT ON COLUMN public.user_preferences.digest_day + IS 'Day of month on which the digest is sent (1–28)'; +COMMENT ON COLUMN public.user_preferences.include_year_to_date + IS 'Include year-to-date spend section in the digest'; + +-- 2. Digest audit log table +-- ───────────────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS public.digest_audit_log ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + digest_type TEXT NOT NULL DEFAULT 'monthly' + CHECK (digest_type IN ('monthly', 'test')), + period_label TEXT NOT NULL, -- e.g. "March 2025" + status TEXT NOT NULL DEFAULT 'sent' + CHECK (status IN ('sent', 'failed', 'skipped')), + error_message TEXT, + sent_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +ALTER TABLE public.digest_audit_log ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "digest_audit_select_own" + ON public.digest_audit_log FOR SELECT + USING (auth.uid() = user_id); + +CREATE INDEX IF NOT EXISTS digest_audit_user_id_idx + ON public.digest_audit_log(user_id); + +CREATE INDEX IF NOT EXISTS digest_audit_sent_at_idx + ON public.digest_audit_log(sent_at DESC); + +COMMENT ON TABLE public.digest_audit_log + IS 'Audit trail for every digest email attempted'; \ No newline at end of file diff --git a/supabase/migrations/20240117000000_add_2fa_tables.sql b/supabase/migrations/20240117000000_add_2fa_tables.sql new file mode 100644 index 0000000..3540e0f --- /dev/null +++ b/supabase/migrations/20240117000000_add_2fa_tables.sql @@ -0,0 +1,34 @@ +-- Migration: Add 2FA support tables and columns +-- Requirements: 1.5, 2.2, 4.2, 5.2 + +-- Recovery codes table for storing bcrypt-hashed one-time-use codes +create table public.recovery_codes ( + id uuid primary key default gen_random_uuid(), + user_id uuid not null references auth.users(id) on delete cascade, + code_hash text not null, + used_at timestamptz, + created_at timestamptz not null default now() +); + +create index recovery_codes_user_id_idx on public.recovery_codes(user_id); + +alter table public.recovery_codes enable row level security; + +-- Users can only read their own recovery codes (inserts use service role key) +create policy "recovery_codes_select_own" + on public.recovery_codes for select + using (auth.uid() = user_id); + +-- Users can delete their own recovery codes +create policy "recovery_codes_delete_own" + on public.recovery_codes for delete + using (auth.uid() = user_id); + +-- Add team-level 2FA enforcement columns +alter table public.teams + add column if not exists require_2fa boolean not null default false, + add column if not exists require_2fa_set_at timestamptz; + +-- Add 2FA enabled timestamp to profiles +alter table public.profiles + add column if not exists two_fa_enabled_at timestamptz; diff --git a/supabase/migrations/20240117010000_subscription_classifications.sql b/supabase/migrations/20240117010000_subscription_classifications.sql new file mode 100644 index 0000000..e33a176 --- /dev/null +++ b/supabase/migrations/20240117010000_subscription_classifications.sql @@ -0,0 +1,20 @@ +-- Migration: subscription_classifications cache table +-- Stores LLM classification results to avoid repeated API calls. + +CREATE TABLE IF NOT EXISTS public.subscription_classifications ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + service_name TEXT NOT NULL, + category TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT subscription_classifications_service_name_unique UNIQUE (service_name) +); + +CREATE INDEX IF NOT EXISTS subscription_classifications_service_name_idx + ON public.subscription_classifications (service_name); + +COMMENT ON TABLE public.subscription_classifications + IS 'Cache of LLM-derived subscription category classifications'; +COMMENT ON COLUMN public.subscription_classifications.service_name + IS 'Normalised service name (lowercase, trimmed)'; +COMMENT ON COLUMN public.subscription_classifications.category + IS 'One of: entertainment, productivity, ai_tools, infrastructure, education, health, finance, other'; \ No newline at end of file diff --git a/supabase/seed.sql b/supabase/seed.sql new file mode 100644 index 0000000..59dc7d5 --- /dev/null +++ b/supabase/seed.sql @@ -0,0 +1,44 @@ +-- Seed data for local development +-- WARNING: Do NOT run this in staging or production environments. +-- Uses fake data only — no real emails, payment info, or PII. + +-- ───────────────────────────────────────────────────────────── +-- Test users (created via Supabase auth.users directly for local dev) +-- In practice, use `supabase auth` or the Studio UI to create users. +-- These UUIDs are stable so foreign keys work across seed runs. +-- ───────────────────────────────────────────────────────────── + +-- Seed user preferences for any existing auth users +INSERT INTO public.user_preferences (user_id) +SELECT id FROM auth.users +ON CONFLICT (user_id) DO NOTHING; + +-- ───────────────────────────────────────────────────────────── +-- Sample subscriptions (requires at least one user in auth.users) +-- ───────────────────────────────────────────────────────────── +DO $$ +DECLARE + v_user_id UUID; +BEGIN + SELECT id INTO v_user_id FROM auth.users LIMIT 1; + + IF v_user_id IS NULL THEN + RAISE NOTICE 'No users found — skipping subscription seed. Create a user via Supabase Studio first.'; + RETURN; + END IF; + + INSERT INTO public.subscriptions (user_id, name, amount, billing_cycle, status, next_billing_date) + VALUES + (v_user_id, 'Netflix', 15.99, 'monthly', 'active', NOW() + INTERVAL '15 days'), + (v_user_id, 'Spotify', 9.99, 'monthly', 'active', NOW() + INTERVAL '22 days'), + (v_user_id, 'GitHub Pro', 10.00, 'monthly', 'active', NOW() + INTERVAL '5 days'), + (v_user_id, 'AWS', 42.00, 'monthly', 'active', NOW() + INTERVAL '3 days'), + (v_user_id, 'Figma', 15.00, 'monthly', 'cancelled', NOW() - INTERVAL '10 days'), + (v_user_id, 'Linear', 8.00, 'monthly', 'trial', NOW() + INTERVAL '7 days'), + (v_user_id, 'Vercel Pro', 20.00, 'monthly', 'active', NOW() + INTERVAL '18 days'), + (v_user_id, 'Adobe CC', 54.99, 'yearly', 'active', NOW() + INTERVAL '200 days') + ON CONFLICT DO NOTHING; + + RAISE NOTICE 'Seeded subscriptions for user %', v_user_id; +END; +$$;