diff --git a/frontend/package.json b/frontend/package.json index 9af9949..6c17171 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -4,8 +4,9 @@ "description": "PredictIQ Landing Page with WCAG 2.1 AA Compliance", "private": true, "scripts": { + "generate-client": "openapi-typescript ../../services/api/openapi.yaml -o src/lib/api/schema.d.ts", "dev": "next dev", - "build": "next build", + "build": "npm run generate-client && next build", "start": "next start", "lint": "next lint", "test": "jest", @@ -51,6 +52,7 @@ "jest-environment-jsdom": "^29.7.0", "lighthouse": "^11.4.0", "pa11y": "^7.0.0", + "openapi-typescript": "^7.0.0", "typescript": "^5.3.3" }, "engines": { diff --git a/frontend/src/lib/api/client.ts b/frontend/src/lib/api/client.ts new file mode 100644 index 0000000..98fed97 --- /dev/null +++ b/frontend/src/lib/api/client.ts @@ -0,0 +1,131 @@ +/** + * Type-safe API client generated from the OpenAPI schema. + * Run `npm run generate-client` to regenerate `schema.d.ts` after API changes. + */ + +const BASE_URL = + process.env.NEXT_PUBLIC_API_URL?.replace(/\/$/, "") ?? "http://localhost:3001"; + +type HttpMethod = "GET" | "POST" | "DELETE"; + +async function request( + method: HttpMethod, + path: string, + options: { body?: unknown; params?: Record } = {} +): Promise { + let url = `${BASE_URL}${path}`; + + if (options.params) { + const qs = new URLSearchParams(); + for (const [k, v] of Object.entries(options.params)) { + if (v !== undefined) qs.set(k, String(v)); + } + const str = qs.toString(); + if (str) url += `?${str}`; + } + + const res = await fetch(url, { + method, + headers: { "Content-Type": "application/json" }, + body: options.body !== undefined ? JSON.stringify(options.body) : undefined, + }); + + if (!res.ok) { + const err = await res.json().catch(() => ({ message: res.statusText })); + throw new Error(err?.message ?? `HTTP ${res.status}`); + } + + // 204 / empty body + const text = await res.text(); + return text ? (JSON.parse(text) as T) : (undefined as unknown as T); +} + +// --------------------------------------------------------------------------- +// Public endpoints +// --------------------------------------------------------------------------- + +export const api = { + health: () => request("GET", "/health"), + + getStatistics: () => request>("GET", "/api/statistics"), + + getFeaturedMarkets: () => + request< + Array<{ + id: number; + title: string; + volume: number; + ends_at: string; + onchain_volume: string; + resolved_outcome?: number | null; + }> + >("GET", "/api/markets/featured"), + + getContent: (params?: { page?: number; page_size?: number }) => + request>("GET", "/api/content", { params }), + + // Blockchain + getBlockchainHealth: () => + request>("GET", "/api/blockchain/health"), + + getBlockchainMarket: (marketId: number | string) => + request>("GET", `/api/blockchain/markets/${marketId}`), + + getBlockchainStats: () => + request>("GET", "/api/blockchain/stats"), + + getUserBets: (user: string, params?: { page?: number; page_size?: number }) => + request>("GET", `/api/blockchain/users/${user}/bets`, { params }), + + getOracleResult: (marketId: number | string) => + request>("GET", `/api/blockchain/oracle/${marketId}`), + + getTransactionStatus: (txHash: string) => + request>("GET", `/api/blockchain/tx/${txHash}`), + + // Newsletter + newsletterSubscribe: (body: { email: string; source?: string }) => + request<{ success: boolean; message: string }>("POST", "/api/v1/newsletter/subscribe", { body }), + + newsletterConfirm: (token: string) => + request<{ success: boolean; message: string }>("GET", `/api/v1/newsletter/confirm`, { + params: { token }, + }), + + newsletterUnsubscribe: (email: string) => + request<{ success: boolean; message: string }>("DELETE", "/api/v1/newsletter/unsubscribe", { + body: { email }, + }), + + newsletterGdprExport: (email: string) => + request<{ success: boolean; data: Record }>( + "GET", + "/api/v1/newsletter/gdpr/export", + { params: { email } } + ), + + newsletterGdprDelete: (email: string) => + request<{ success: boolean; message: string }>("DELETE", "/api/v1/newsletter/gdpr/delete", { + body: { email }, + }), + + // Admin / email + resolveMarket: (marketId: number | string) => + request<{ invalidated_keys: number }>("POST", `/api/markets/${marketId}/resolve`), + + emailPreview: (templateName: string) => + request>("GET", `/api/v1/email/preview/${templateName}`), + + emailSendTest: (body: { recipient: string; template_name: string }) => + request<{ success: boolean; message: string; message_id: string }>( + "POST", + "/api/v1/email/test", + { body } + ), + + getEmailAnalytics: (params?: { template_name?: string; days?: number }) => + request>("GET", "/api/v1/email/analytics", { params }), + + getEmailQueueStats: () => + request>("GET", "/api/v1/email/queue/stats"), +}; diff --git a/services/api/openapi.yaml b/services/api/openapi.yaml new file mode 100644 index 0000000..214baef --- /dev/null +++ b/services/api/openapi.yaml @@ -0,0 +1,556 @@ +openapi: 3.0.3 +info: + title: PredictIQ API + version: 1.0.0 + description: REST API for the PredictIQ prediction markets platform + +servers: + - url: http://localhost:3001 + description: Local development + +tags: + - name: health + - name: markets + - name: blockchain + - name: newsletter + - name: email + - name: webhooks + +paths: + /health: + get: + tags: [health] + operationId: getHealth + summary: Health check + responses: + "200": + description: Service is healthy + content: + text/plain: + schema: + type: string + example: ok + + /api/statistics: + get: + tags: [markets] + operationId: getStatistics + summary: Platform statistics + responses: + "200": + description: Statistics payload + content: + application/json: + schema: + $ref: "#/components/schemas/AnyObject" + "500": + $ref: "#/components/responses/ApiError" + + /api/markets/featured: + get: + tags: [markets] + operationId: getFeaturedMarkets + summary: List featured markets + responses: + "200": + description: Array of featured markets + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/FeaturedMarketView" + "500": + $ref: "#/components/responses/ApiError" + + /api/content: + get: + tags: [markets] + operationId: getContent + summary: Paginated content feed + parameters: + - $ref: "#/components/parameters/page" + - $ref: "#/components/parameters/pageSize" + responses: + "200": + description: Content payload + content: + application/json: + schema: + $ref: "#/components/schemas/AnyObject" + "500": + $ref: "#/components/responses/ApiError" + + /api/markets/{market_id}/resolve: + post: + tags: [markets] + operationId: resolveMarket + summary: Resolve a market and invalidate caches (admin) + parameters: + - $ref: "#/components/parameters/marketId" + responses: + "200": + description: Cache invalidation result + content: + application/json: + schema: + $ref: "#/components/schemas/InvalidationResult" + "500": + $ref: "#/components/responses/ApiError" + + /api/blockchain/health: + get: + tags: [blockchain] + operationId: getBlockchainHealth + summary: Blockchain node health + responses: + "200": + description: Health data + content: + application/json: + schema: + $ref: "#/components/schemas/AnyObject" + "500": + $ref: "#/components/responses/ApiError" + + /api/blockchain/markets/{market_id}: + get: + tags: [blockchain] + operationId: getBlockchainMarket + summary: On-chain market data + parameters: + - $ref: "#/components/parameters/marketId" + responses: + "200": + description: Market chain data + content: + application/json: + schema: + $ref: "#/components/schemas/AnyObject" + "500": + $ref: "#/components/responses/ApiError" + + /api/blockchain/stats: + get: + tags: [blockchain] + operationId: getBlockchainStats + summary: Platform on-chain statistics + responses: + "200": + description: Platform stats + content: + application/json: + schema: + $ref: "#/components/schemas/AnyObject" + "500": + $ref: "#/components/responses/ApiError" + + /api/blockchain/users/{user}/bets: + get: + tags: [blockchain] + operationId: getUserBets + summary: Bets placed by a user + parameters: + - name: user + in: path + required: true + schema: + type: string + description: Stellar address of the user + - $ref: "#/components/parameters/page" + - $ref: "#/components/parameters/pageSize" + responses: + "200": + description: User bets + content: + application/json: + schema: + $ref: "#/components/schemas/AnyObject" + "500": + $ref: "#/components/responses/ApiError" + + /api/blockchain/oracle/{market_id}: + get: + tags: [blockchain] + operationId: getOracleResult + summary: Oracle result for a market + parameters: + - $ref: "#/components/parameters/marketId" + responses: + "200": + description: Oracle result + content: + application/json: + schema: + $ref: "#/components/schemas/AnyObject" + "500": + $ref: "#/components/responses/ApiError" + + /api/blockchain/tx/{tx_hash}: + get: + tags: [blockchain] + operationId: getTransactionStatus + summary: Transaction status by hash + parameters: + - name: tx_hash + in: path + required: true + schema: + type: string + responses: + "200": + description: Transaction status + content: + application/json: + schema: + $ref: "#/components/schemas/AnyObject" + "500": + $ref: "#/components/responses/ApiError" + + /api/v1/newsletter/subscribe: + post: + tags: [newsletter] + operationId: newsletterSubscribe + summary: Subscribe to newsletter + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/NewsletterSubscribeRequest" + responses: + "200": + $ref: "#/components/responses/NewsletterResponse" + "400": + $ref: "#/components/responses/NewsletterResponse" + "409": + $ref: "#/components/responses/NewsletterResponse" + "429": + $ref: "#/components/responses/NewsletterResponse" + + /api/v1/newsletter/confirm: + get: + tags: [newsletter] + operationId: newsletterConfirm + summary: Confirm newsletter subscription via token + parameters: + - name: token + in: query + required: true + schema: + type: string + responses: + "200": + $ref: "#/components/responses/NewsletterResponse" + "400": + $ref: "#/components/responses/NewsletterResponse" + "404": + $ref: "#/components/responses/NewsletterResponse" + + /api/v1/newsletter/unsubscribe: + delete: + tags: [newsletter] + operationId: newsletterUnsubscribe + summary: Unsubscribe from newsletter + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/EmailRequest" + responses: + "200": + $ref: "#/components/responses/NewsletterResponse" + "400": + $ref: "#/components/responses/NewsletterResponse" + + /api/v1/newsletter/gdpr/export: + get: + tags: [newsletter] + operationId: newsletterGdprExport + summary: GDPR data export for a subscriber + parameters: + - name: email + in: query + required: true + schema: + type: string + format: email + responses: + "200": + description: Subscriber data + content: + application/json: + schema: + $ref: "#/components/schemas/NewsletterExportResponse" + "400": + $ref: "#/components/responses/NewsletterResponse" + "404": + $ref: "#/components/responses/NewsletterResponse" + + /api/v1/newsletter/gdpr/delete: + delete: + tags: [newsletter] + operationId: newsletterGdprDelete + summary: GDPR data deletion for a subscriber + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/EmailRequest" + responses: + "200": + $ref: "#/components/responses/NewsletterResponse" + "400": + $ref: "#/components/responses/NewsletterResponse" + + /api/v1/email/preview/{template_name}: + get: + tags: [email] + operationId: emailPreview + summary: Preview a rendered email template (admin) + parameters: + - name: template_name + in: path + required: true + schema: + type: string + enum: + - newsletter_confirmation + - waitlist_confirmation + - contact_form_auto_response + - welcome_email + responses: + "200": + description: Rendered email preview + content: + application/json: + schema: + $ref: "#/components/schemas/AnyObject" + "500": + $ref: "#/components/responses/ApiError" + + /api/v1/email/test: + post: + tags: [email] + operationId: emailSendTest + summary: Send a test email (admin) + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/EmailTestRequest" + responses: + "200": + description: Test email result + content: + application/json: + schema: + $ref: "#/components/schemas/EmailTestResponse" + "500": + $ref: "#/components/responses/ApiError" + + /api/v1/email/analytics: + get: + tags: [email] + operationId: getEmailAnalytics + summary: Email analytics (admin) + parameters: + - name: template_name + in: query + required: false + schema: + type: string + - name: days + in: query + required: false + schema: + type: integer + minimum: 1 + maximum: 365 + default: 30 + responses: + "200": + description: Analytics data + content: + application/json: + schema: + $ref: "#/components/schemas/AnyObject" + "500": + $ref: "#/components/responses/ApiError" + + /api/v1/email/queue/stats: + get: + tags: [email] + operationId: getEmailQueueStats + summary: Email queue statistics (admin) + responses: + "200": + description: Queue stats + content: + application/json: + schema: + $ref: "#/components/schemas/AnyObject" + "500": + $ref: "#/components/responses/ApiError" + + /webhooks/sendgrid: + post: + tags: [webhooks] + operationId: sendgridWebhook + summary: SendGrid event webhook receiver + requestBody: + required: true + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/AnyObject" + responses: + "200": + description: Webhook processed + +components: + parameters: + marketId: + name: market_id + in: path + required: true + schema: + type: integer + format: int64 + page: + name: page + in: query + required: false + schema: + type: integer + format: int64 + minimum: 1 + default: 1 + pageSize: + name: page_size + in: query + required: false + schema: + type: integer + format: int64 + minimum: 1 + maximum: 100 + default: 20 + + schemas: + AnyObject: + type: object + additionalProperties: true + + ApiError: + type: object + required: [message] + properties: + message: + type: string + + FeaturedMarketView: + type: object + required: [id, title, volume, ends_at, onchain_volume] + properties: + id: + type: integer + format: int64 + title: + type: string + volume: + type: number + format: double + ends_at: + type: string + format: date-time + onchain_volume: + type: string + resolved_outcome: + type: integer + format: int32 + nullable: true + + InvalidationResult: + type: object + required: [invalidated_keys] + properties: + invalidated_keys: + type: integer + + NewsletterSubscribeRequest: + type: object + required: [email] + properties: + email: + type: string + format: email + source: + type: string + maxLength: 64 + + EmailRequest: + type: object + required: [email] + properties: + email: + type: string + format: email + + NewsletterResponse: + type: object + required: [success, message] + properties: + success: + type: boolean + message: + type: string + + NewsletterExportResponse: + type: object + required: [success, data] + properties: + success: + type: boolean + data: + $ref: "#/components/schemas/AnyObject" + + EmailTestRequest: + type: object + required: [recipient, template_name] + properties: + recipient: + type: string + format: email + template_name: + type: string + + EmailTestResponse: + type: object + required: [success, message, message_id] + properties: + success: + type: boolean + message: + type: string + message_id: + type: string + + responses: + ApiError: + description: Internal server error + content: + application/json: + schema: + $ref: "#/components/schemas/ApiError" + NewsletterResponse: + description: Newsletter operation result + content: + application/json: + schema: + $ref: "#/components/schemas/NewsletterResponse"