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/backend/package.json b/backend/package.json index 5dafc3e..6e81e52 100644 --- a/backend/package.json +++ b/backend/package.json @@ -11,7 +11,8 @@ "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" }, "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);