From 45e1a2db95a68f1745266327e67ce3cf31f410f9 Mon Sep 17 00:00:00 2001 From: waterWang Date: Tue, 28 Jul 2026 19:39:56 +0800 Subject: [PATCH] feat: add UserClient and SubmissionsClient modules for comprehensive SDK coverage (#863) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add dedicated user and submission resource modules to the SolFoundry TypeScript SDK, completing the API coverage for bounties, submissions, and users as specified in the bounty requirements. Changes: - Add sdk/src/users.ts — UserClient with profile management, activity history, contribution stats, and notification preferences - Add sdk/src/submissions.ts — SubmissionsClient with dedicated submission lifecycle operations (submit, list, review, status updates) - Add sdk/src/types.ts types — UserProfile, UserActivityEntry, UserContributionStats, UserNotification, ModelReviewResult, SubmissionReviewResponse, and supporting types - Update sdk/src/index.ts — Export new modules, add users and submissions to the SolFoundry facade class - Add tests — users.test.ts (11 tests) and submissions.test.ts (6 tests) following existing vitest mocking patterns - Update index.test.ts — Verify new clients are accessible via facade Test: 220 passed (12 files), all existing tests continue to pass [fFaaFyfxR9WAQrL7FcAgEHJvztd8cVMxvjHRS55rw1nwH] --- sdk/src/__tests__/index.test.ts | 4 + sdk/src/__tests__/submissions.test.ts | 193 ++++++++++++++++++ sdk/src/__tests__/users.test.ts | 278 ++++++++++++++++++++++++++ sdk/src/index.ts | 33 +++ sdk/src/submissions.ts | 161 +++++++++++++++ sdk/src/types.ts | 204 +++++++++++++++++++ sdk/src/users.ts | 227 +++++++++++++++++++++ 7 files changed, 1100 insertions(+) create mode 100644 sdk/src/__tests__/submissions.test.ts create mode 100644 sdk/src/__tests__/users.test.ts create mode 100644 sdk/src/submissions.ts create mode 100644 sdk/src/users.ts diff --git a/sdk/src/__tests__/index.test.ts b/sdk/src/__tests__/index.test.ts index d21dbc3f5..c86d1d0f4 100644 --- a/sdk/src/__tests__/index.test.ts +++ b/sdk/src/__tests__/index.test.ts @@ -11,6 +11,8 @@ import { SolFoundry } from '../index.js'; import { BountyClient } from '../bounties.js'; import { EscrowClient } from '../escrow.js'; import { ContributorClient } from '../contributors.js'; +import { UserClient } from '../users.js'; +import { SubmissionsClient } from '../submissions.js'; import { HttpClient } from '../client.js'; describe('SolFoundry', () => { @@ -24,6 +26,8 @@ describe('SolFoundry', () => { expect(client.bounties).toBeInstanceOf(BountyClient); expect(client.escrow).toBeInstanceOf(EscrowClient); expect(client.contributors).toBeInstanceOf(ContributorClient); + expect(client.users).toBeInstanceOf(UserClient); + expect(client.submissions).toBeInstanceOf(SubmissionsClient); expect(client.http).toBeInstanceOf(HttpClient); }); diff --git a/sdk/src/__tests__/submissions.test.ts b/sdk/src/__tests__/submissions.test.ts new file mode 100644 index 000000000..8261ec321 --- /dev/null +++ b/sdk/src/__tests__/submissions.test.ts @@ -0,0 +1,193 @@ +/** + * Tests for the SubmissionsClient resource module. + * + * Verifies that each method constructs the correct HTTP request + * (path, method, params, body) and delegates to the HttpClient. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { SubmissionsClient } from '../submissions.js'; +import type { HttpClient } from '../client.js'; +import type { + SubmissionCreate, + SubmissionResponse, + SubmissionStatusUpdate, + SubmissionReviewResponse, +} from '../types.js'; +import { SubmissionStatus } from '../types.js'; + +/** Create a mock HttpClient with a vi.fn() for request. */ +function createMockHttpClient(): HttpClient { + return { + request: vi.fn(), + setAuthToken: vi.fn(), + getAuthToken: vi.fn(), + } as unknown as HttpClient; +} + +/** Create a minimal submission fixture. */ +function createSubmissionFixture(overrides?: Partial): SubmissionResponse { + return { + id: 'sub-123', + bounty_id: 'bounty-456', + pr_url: 'https://github.com/owner/repo/pull/42', + submitted_by: 'user-1', + contributor_wallet: '7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU', + notes: null, + status: SubmissionStatus.PENDING, + ai_score: 0, + ai_scores_by_model: {}, + review_complete: false, + meets_threshold: false, + auto_approve_eligible: false, + auto_approve_after: null, + approved_by: null, + approved_at: null, + payout_tx_hash: null, + payout_amount: null, + payout_at: null, + winner: false, + submitted_at: '2026-03-22T00:00:00Z', + ...overrides, + }; +} + +describe('SubmissionsClient', () => { + let http: HttpClient; + let client: SubmissionsClient; + + beforeEach(() => { + http = createMockHttpClient(); + client = new SubmissionsClient(http); + }); + + describe('list', () => { + it('should call GET /api/bounties/:id/submissions', async () => { + const mockSubmissions = [createSubmissionFixture()]; + (http.request as ReturnType).mockResolvedValue(mockSubmissions); + + const result = await client.list('bounty-456'); + + expect(http.request).toHaveBeenCalledWith({ + path: '/api/bounties/bounty-456/submissions', + method: 'GET', + }); + expect(result).toEqual(mockSubmissions); + }); + }); + + describe('get', () => { + it('should call GET /api/bounties/:id/submissions/:subId', async () => { + const mockSubmission = createSubmissionFixture(); + (http.request as ReturnType).mockResolvedValue(mockSubmission); + + const result = await client.get('bounty-456', 'sub-123'); + + expect(http.request).toHaveBeenCalledWith({ + path: '/api/bounties/bounty-456/submissions/sub-123', + method: 'GET', + }); + expect(result).toEqual(mockSubmission); + }); + }); + + describe('create', () => { + it('should call POST /api/bounties/:id/submissions with auth', async () => { + const createData: SubmissionCreate = { + pr_url: 'https://github.com/owner/repo/pull/99', + notes: 'Fixes the issue', + }; + const mockSubmission = createSubmissionFixture({ + pr_url: 'https://github.com/owner/repo/pull/99', + notes: 'Fixes the issue', + }); + (http.request as ReturnType).mockResolvedValue(mockSubmission); + + const result = await client.create('bounty-456', createData); + + expect(http.request).toHaveBeenCalledWith({ + path: '/api/bounties/bounty-456/submissions', + method: 'POST', + body: createData, + requiresAuth: true, + }); + expect(result).toEqual(mockSubmission); + }); + + it('should include optional wallet address', async () => { + const createData: SubmissionCreate = { + pr_url: 'https://github.com/owner/repo/pull/100', + contributor_wallet: '7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU', + }; + (http.request as ReturnType).mockResolvedValue( + createSubmissionFixture({ pr_url: 'https://github.com/owner/repo/pull/100' }), + ); + + await client.create('bounty-456', createData); + + expect(http.request).toHaveBeenCalledWith({ + path: '/api/bounties/bounty-456/submissions', + method: 'POST', + body: createData, + requiresAuth: true, + }); + }); + }); + + describe('updateStatus', () => { + it('should call PATCH /api/bounties/:id/submissions/:subId/status with auth', async () => { + const statusUpdate: SubmissionStatusUpdate = { status: 'approved' }; + const mockSubmission = createSubmissionFixture({ + status: SubmissionStatus.APPROVED, + approved_by: 'user-1', + approved_at: '2026-03-23T00:00:00Z', + }); + (http.request as ReturnType).mockResolvedValue(mockSubmission); + + const result = await client.updateStatus('bounty-456', 'sub-123', statusUpdate); + + expect(http.request).toHaveBeenCalledWith({ + path: '/api/bounties/bounty-456/submissions/sub-123/status', + method: 'PATCH', + body: statusUpdate, + requiresAuth: true, + }); + expect(result.status).toBe(SubmissionStatus.APPROVED); + }); + }); + + describe('getReview', () => { + it('should call GET /api/bounties/:id/submissions/:subId/review', async () => { + const mockReview: SubmissionReviewResponse = { + submission_id: 'sub-123', + bounty_id: 'bounty-456', + aggregated_score: 8.5, + meets_threshold: true, + review_complete: true, + auto_approve_eligible: true, + model_reviews: [ + { + model_name: 'claude', + score: 9, + summary: 'Well-structured solution', + detailed_feedback: 'Good code quality and tests', + has_critical_issues: false, + issues: [], + reviewed_at: '2026-03-23T00:00:00Z', + }, + ], + reviewed_at: '2026-03-23T00:00:00Z', + }; + (http.request as ReturnType).mockResolvedValue(mockReview); + + const result = await client.getReview('bounty-456', 'sub-123'); + + expect(http.request).toHaveBeenCalledWith({ + path: '/api/bounties/bounty-456/submissions/sub-123/review', + method: 'GET', + }); + expect(result).toEqual(mockReview); + expect(result.model_reviews[0].model_name).toBe('claude'); + }); + }); +}); \ No newline at end of file diff --git a/sdk/src/__tests__/users.test.ts b/sdk/src/__tests__/users.test.ts new file mode 100644 index 000000000..a65fb0013 --- /dev/null +++ b/sdk/src/__tests__/users.test.ts @@ -0,0 +1,278 @@ +/** + * Tests for the UserClient resource module. + * + * Verifies that each method constructs the correct HTTP request + * (path, method, params, body) and delegates to the HttpClient. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { UserClient } from '../users.js'; +import type { HttpClient } from '../client.js'; +import type { + UserProfile, + UserActivityResponse, + UserContributionStats, + UserNotificationResponse, +} from '../types.js'; + +/** Create a mock HttpClient with a vi.fn() for request. */ +function createMockHttpClient(): HttpClient { + return { + request: vi.fn(), + setAuthToken: vi.fn(), + getAuthToken: vi.fn(), + } as unknown as HttpClient; +} + +/** Create a minimal user profile fixture. */ +function createUserProfileFixture(overrides?: Partial): UserProfile { + return { + id: 'user-123', + username: 'testuser', + display_name: 'Test User', + email: 'test@example.com', + wallet_address: '7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU', + avatar_url: null, + bio: 'A test user', + skills: ['typescript', 'python'], + badges: ['early-adopter'], + reputation_score: 42, + total_bounties_completed: 5, + total_earned: 1500, + tier_unlocked: 2, + created_at: '2026-03-22T00:00:00Z', + updated_at: '2026-03-22T00:00:00Z', + ...overrides, + }; +} + +describe('UserClient', () => { + let http: HttpClient; + let client: UserClient; + + beforeEach(() => { + http = createMockHttpClient(); + client = new UserClient(http); + }); + + describe('getProfile', () => { + it('should call GET /api/users/me with auth', async () => { + const mockProfile = createUserProfileFixture(); + (http.request as ReturnType).mockResolvedValue(mockProfile); + + const result = await client.getProfile(); + + expect(http.request).toHaveBeenCalledWith({ + path: '/api/users/me', + method: 'GET', + requiresAuth: true, + }); + expect(result).toEqual(mockProfile); + }); + }); + + describe('updateProfile', () => { + it('should call PATCH /api/users/me with auth and body', async () => { + const updateData = { display_name: 'New Name', bio: 'Updated bio' }; + const mockProfile = createUserProfileFixture({ display_name: 'New Name', bio: 'Updated bio' }); + (http.request as ReturnType).mockResolvedValue(mockProfile); + + const result = await client.updateProfile(updateData); + + expect(http.request).toHaveBeenCalledWith({ + path: '/api/users/me', + method: 'PATCH', + body: updateData, + requiresAuth: true, + }); + expect(result.display_name).toBe('New Name'); + }); + }); + + describe('getActivity', () => { + it('should call GET /api/users/me/activity with auth', async () => { + const mockResponse: UserActivityResponse = { + items: [], + total: 0, + skip: 0, + limit: 20, + }; + (http.request as ReturnType).mockResolvedValue(mockResponse); + + const result = await client.getActivity(); + + expect(http.request).toHaveBeenCalledWith({ + path: '/api/users/me/activity', + method: 'GET', + params: { skip: undefined, limit: undefined }, + requiresAuth: true, + }); + expect(result).toEqual(mockResponse); + }); + + it('should pass pagination params', async () => { + (http.request as ReturnType).mockResolvedValue({ + items: [], + total: 0, + skip: 10, + limit: 5, + }); + + await client.getActivity({ skip: 10, limit: 5 }); + + expect(http.request).toHaveBeenCalledWith({ + path: '/api/users/me/activity', + method: 'GET', + params: { skip: 10, limit: 5 }, + requiresAuth: true, + }); + }); + }); + + describe('getContributionStats', () => { + it('should call GET /api/users/me/stats with auth', async () => { + const mockStats: UserContributionStats = { + total_bounties_completed: 5, + total_bounties_in_progress: 2, + total_fndry_earned: 1500, + average_review_score: 8.2, + auto_approved_count: 3, + tier_unlocked: 2, + bounties_by_tier: { '1': 3, '2': 2 }, + total_submissions: 8, + approval_rate: 0.75, + }; + (http.request as ReturnType).mockResolvedValue(mockStats); + + const result = await client.getContributionStats(); + + expect(http.request).toHaveBeenCalledWith({ + path: '/api/users/me/stats', + method: 'GET', + requiresAuth: true, + }); + expect(result).toEqual(mockStats); + }); + }); + + describe('listNotifications', () => { + it('should call GET /api/users/me/notifications with auth', async () => { + const mockResponse: UserNotificationResponse = { + items: [], + total: 0, + unread_count: 0, + skip: 0, + limit: 20, + }; + (http.request as ReturnType).mockResolvedValue(mockResponse); + + const result = await client.listNotifications(); + + expect(http.request).toHaveBeenCalledWith({ + path: '/api/users/me/notifications', + method: 'GET', + params: { unread_only: undefined, skip: undefined, limit: undefined }, + requiresAuth: true, + }); + expect(result).toEqual(mockResponse); + }); + + it('should pass filter params', async () => { + (http.request as ReturnType).mockResolvedValue({ + items: [], + total: 0, + unread_count: 0, + skip: 0, + limit: 10, + }); + + await client.listNotifications({ unread_only: true, skip: 0, limit: 10 }); + + expect(http.request).toHaveBeenCalledWith({ + path: '/api/users/me/notifications', + method: 'GET', + params: { unread_only: true, skip: 0, limit: 10 }, + requiresAuth: true, + }); + }); + }); + + describe('markNotificationRead', () => { + it('should call POST /api/users/me/notifications/:id/read with auth', async () => { + (http.request as ReturnType).mockResolvedValue({ + notification: { id: 'notif-1', read: true }, + unread_count: 3, + }); + + const result = await client.markNotificationRead('notif-1'); + + expect(http.request).toHaveBeenCalledWith({ + path: '/api/users/me/notifications/notif-1/read', + method: 'POST', + requiresAuth: true, + }); + expect(result.notification.read).toBe(true); + }); + }); + + describe('markAllNotificationsRead', () => { + it('should call POST /api/users/me/notifications/read-all with auth', async () => { + (http.request as ReturnType).mockResolvedValue({ success: true }); + + const result = await client.markAllNotificationsRead(); + + expect(http.request).toHaveBeenCalledWith({ + path: '/api/users/me/notifications/read-all', + method: 'POST', + requiresAuth: true, + }); + expect(result.success).toBe(true); + }); + }); + + describe('getNotificationPreferences', () => { + it('should call GET /api/users/me/notifications/preferences with auth', async () => { + const mockPrefs = { + email_on_review: true, + email_on_completion: true, + email_on_payout: true, + email_on_new_bounty: false, + in_app_notifications: true, + }; + (http.request as ReturnType).mockResolvedValue(mockPrefs); + + const result = await client.getNotificationPreferences(); + + expect(http.request).toHaveBeenCalledWith({ + path: '/api/users/me/notifications/preferences', + method: 'GET', + requiresAuth: true, + }); + expect(result).toEqual(mockPrefs); + }); + }); + + describe('updateNotificationPreferences', () => { + it('should call PATCH /api/users/me/notifications/preferences with auth', async () => { + const updateData = { email_on_new_bounty: true }; + const mockPrefs = { + email_on_review: true, + email_on_completion: true, + email_on_payout: true, + email_on_new_bounty: true, + in_app_notifications: true, + }; + (http.request as ReturnType).mockResolvedValue(mockPrefs); + + const result = await client.updateNotificationPreferences(updateData); + + expect(http.request).toHaveBeenCalledWith({ + path: '/api/users/me/notifications/preferences', + method: 'PATCH', + body: updateData, + requiresAuth: true, + }); + expect(result.email_on_new_bounty).toBe(true); + }); + }); +}); \ No newline at end of file diff --git a/sdk/src/index.ts b/sdk/src/index.ts index 0ed837573..4b1c1708b 100644 --- a/sdk/src/index.ts +++ b/sdk/src/index.ts @@ -6,6 +6,8 @@ * * - **Bounty operations**: CRUD, search, submissions, autocomplete * - **Escrow management**: Fund, release, refund, audit ledger + * - **User profiles**: Get profile, update, activity history, notifications + * - **Submission management**: Submit, list, review, status updates * - **Contributor profiles**: Create, update, list, stats * - **GitHub integration**: List bounties, check claims, verify completion * - **Solana helpers**: PDA derivation, account deserialization, tx building @@ -44,6 +46,8 @@ export { GitHubClient } from './github.js'; export type { GitHubClientConfig } from './github.js'; export { EventSubscriber } from './events.js'; export type { EventSubscriberConfig, EventHandler, ConnectionHandler, ErrorHandler } from './events.js'; +export { UserClient } from './users.js'; +export { SubmissionsClient } from './submissions.js'; // Solana helpers export { @@ -142,6 +146,19 @@ export type { // Config types SolFoundryClientConfig, ApiErrorResponse, + // User types + UserProfile, + UserProfileUpdate, + UserActivityEntry, + UserActivityResponse, + UserContributionStats, + UserNotification, + UserNotificationResponse, + UserNotificationUpdate, + UserNotificationPreferences, + // Submission review types + ModelReviewResult, + SubmissionReviewResponse, } from './types.js'; // Program clients (on-chain Anchor program interaction) @@ -168,6 +185,8 @@ import { HttpClient } from './client.js'; import { BountyClient } from './bounties.js'; import { EscrowClient } from './escrow.js'; import { ContributorClient } from './contributors.js'; +import { UserClient } from './users.js'; +import { SubmissionsClient } from './submissions.js'; import type { SolFoundryClientConfig } from './types.js'; /** @@ -191,6 +210,12 @@ import type { SolFoundryClientConfig } from './types.js'; * // Access escrow operations * const escrow = await sf.escrow.getStatus('bounty-uuid'); * + * // Access user profile + * const profile = await sf.users.getProfile(); + * + * // Access submission operations + * const submissions = await sf.submissions.list('bounty-uuid'); + * * // Access contributor operations * const stats = await sf.contributors.getStats(); * ``` @@ -205,6 +230,12 @@ export class SolFoundry { /** Client for escrow lifecycle management. */ public readonly escrow: EscrowClient; + /** Client for user profile, activity, and notification management. */ + public readonly users: UserClient; + + /** Client for submission lifecycle operations (submit, list, review). */ + public readonly submissions: SubmissionsClient; + /** Client for contributor profiles and platform statistics. */ public readonly contributors: ContributorClient; @@ -217,6 +248,8 @@ export class SolFoundry { this.http = new HttpClient(config); this.bounties = new BountyClient(this.http); this.escrow = new EscrowClient(this.http); + this.users = new UserClient(this.http); + this.submissions = new SubmissionsClient(this.http); this.contributors = new ContributorClient(this.http); } diff --git a/sdk/src/submissions.ts b/sdk/src/submissions.ts new file mode 100644 index 000000000..affc0eac8 --- /dev/null +++ b/sdk/src/submissions.ts @@ -0,0 +1,161 @@ +/** + * Submission resource module for the SolFoundry SDK. + * + * Provides methods for managing solution submissions to bounties, + * including creating new submissions, listing submissions for a + * bounty, updating submission status, and retrieving AI review results. + * + * @module submissions + */ + +import type { HttpClient } from './client.js'; +import type { + SubmissionCreate, + SubmissionResponse, + SubmissionStatusUpdate, + SubmissionReviewResponse, +} from './types.js'; + +/** + * Client for interacting with the SolFoundry submission API. + * + * Wraps `/api/bounties/{bountyId}/submissions` endpoints with + * type-safe methods. Provides a dedicated interface for all + * submission lifecycle operations separate from bounty management. + * + * @example + * ```typescript + * const submissions = new SubmissionsClient(http); + * + * // Submit a solution to a bounty + * const submission = await submissions.create('bounty-uuid', { + * pr_url: 'https://github.com/owner/repo/pull/42', + * }); + * + * // List all submissions for a bounty + * const all = await submissions.list('bounty-uuid'); + * + * // Get AI review details + * const review = await submissions.getReview('bounty-uuid', 'submission-uuid'); + * ``` + */ +export class SubmissionsClient { + private readonly http: HttpClient; + + /** + * Create a new SubmissionsClient. + * + * @param http - The configured HTTP client for API communication. + */ + constructor(http: HttpClient) { + this.http = http; + } + + /** + * List all submissions for a specific bounty. + * + * Returns all solution submissions including their review scores, + * approval status, and payout information. Results are sorted by + * submission date (newest first). + * + * @param bountyId - The UUID of the bounty whose submissions to list. + * @returns Array of submission responses. + * @throws {NotFoundError} If the bounty does not exist. + */ + async list(bountyId: string): Promise { + return this.http.request({ + path: `/api/bounties/${bountyId}/submissions`, + method: 'GET', + }); + } + + /** + * Get a specific submission by its UUID. + * + * Returns the full submission details including AI review scores, + * approval status, and payout information. + * + * @param bountyId - The UUID of the parent bounty. + * @param submissionId - The UUID of the submission to retrieve. + * @returns The full submission response. + * @throws {NotFoundError} If the bounty or submission does not exist. + */ + async get(bountyId: string, submissionId: string): Promise { + return this.http.request({ + path: `/api/bounties/${bountyId}/submissions/${submissionId}`, + method: 'GET', + }); + } + + /** + * Submit a solution (pull request) to a bounty. + * + * Creates a new submission that will be reviewed by the multi-LLM + * pipeline. Requires authentication. + * + * @param bountyId - The UUID of the bounty to submit a solution for. + * @param data - Submission payload with PR URL and optional metadata. + * @returns The created submission with review status fields. + * @throws {NotFoundError} If the bounty does not exist. + * @throws {ValidationError} If the submission data is invalid. + * @throws {AuthenticationError} If not authenticated. + */ + async create(bountyId: string, data: SubmissionCreate): Promise { + return this.http.request({ + path: `/api/bounties/${bountyId}/submissions`, + method: 'POST', + body: data, + requiresAuth: true, + }); + } + + /** + * Update the status of a specific submission. + * + * Used for approving, rejecting, or otherwise transitioning a + * submission through its lifecycle. Requires authentication and + * ownership of the parent bounty. + * + * @param bountyId - The UUID of the parent bounty. + * @param submissionId - The UUID of the submission to update. + * @param data - New status value. + * @returns The updated submission response. + * @throws {NotFoundError} If the bounty or submission does not exist. + * @throws {ConflictError} If the state transition is not allowed. + * @throws {AuthorizationError} If the user does not own the bounty. + */ + async updateStatus( + bountyId: string, + submissionId: string, + data: SubmissionStatusUpdate, + ): Promise { + return this.http.request({ + path: `/api/bounties/${bountyId}/submissions/${submissionId}/status`, + method: 'PATCH', + body: data, + requiresAuth: true, + }); + } + + /** + * Get the AI review details for a submission. + * + * Returns the complete multi-LLM review results including individual + * model scores, aggregated score, model-specific feedback, and + * detailed review comments. + * + * @param bountyId - The UUID of the parent bounty. + * @param submissionId - The UUID of the submission to review. + * @returns AI review details with per-model scores and feedback. + * @throws {NotFoundError} If the bounty or submission does not exist. + */ + async getReview( + bountyId: string, + submissionId: string, + ): Promise { + return this.http.request({ + path: `/api/bounties/${bountyId}/submissions/${submissionId}/review`, + method: 'GET', + }); + } +} \ No newline at end of file diff --git a/sdk/src/types.ts b/sdk/src/types.ts index 43ff3e23e..69076d050 100644 --- a/sdk/src/types.ts +++ b/sdk/src/types.ts @@ -677,3 +677,207 @@ export interface ApiErrorResponse { /** Machine-readable error code. */ readonly code: string; } + +// --------------------------------------------------------------------------- +// User types +// --------------------------------------------------------------------------- + +/** Full user profile returned from the API. */ +export interface UserProfile { + /** Unique user UUID. */ + readonly id: string; + /** GitHub username. */ + readonly username: string; + /** Display name. */ + readonly display_name: string | null; + /** Email address (only visible to the authenticated user). */ + readonly email: string | null; + /** Solana wallet address. */ + readonly wallet_address: string | null; + /** Avatar URL. */ + readonly avatar_url: string | null; + /** Short bio. */ + readonly bio: string | null; + /** Technical skills. */ + readonly skills: string[]; + /** Earned badges. */ + readonly badges: string[]; + /** Current reputation score. */ + readonly reputation_score: number; + /** Total bounties completed. */ + readonly total_bounties_completed: number; + /** Total $FNDRY earned. */ + readonly total_earned: number; + /** Current unlocked tier. */ + readonly tier_unlocked: number; + /** ISO timestamp of account creation. */ + readonly created_at: string; + /** ISO timestamp of last profile update. */ + readonly updated_at: string; +} + +/** Payload for updating the authenticated user's profile. */ +export interface UserProfileUpdate { + /** Updated display name. */ + readonly display_name?: string; + /** Updated Solana wallet address (32-44 chars base58). */ + readonly wallet_address?: string; + /** Updated avatar URL. */ + readonly avatar_url?: string; + /** Updated bio (max 500 chars). */ + readonly bio?: string; + /** Updated skills list. */ + readonly skills?: string[]; +} + +/** A single activity entry in the user's activity history. */ +export interface UserActivityEntry { + /** Unique activity UUID. */ + readonly id: string; + /** Type of activity (e.g., "submission_created", "bounty_completed", "payout_received"). */ + readonly activity_type: string; + /** Human-readable description of the activity. */ + readonly description: string; + /** Associated bounty UUID (if applicable). */ + readonly bounty_id: string | null; + /** Associated bounty title (if applicable). */ + readonly bounty_title: string | null; + /** Reward amount in $FNDRY (if applicable). */ + readonly reward_amount: number | null; + /** ISO timestamp of the activity. */ + readonly created_at: string; +} + +/** Paginated user activity response. */ +export interface UserActivityResponse { + /** Array of activity entries. */ + readonly items: UserActivityEntry[]; + /** Total number of activities matching the query. */ + readonly total: number; + /** Pagination offset. */ + readonly skip: number; + /** Page size. */ + readonly limit: number; +} + +/** Aggregated contribution statistics for a user. */ +export interface UserContributionStats { + /** Total bounties completed. */ + readonly total_bounties_completed: number; + /** Total bounties in progress. */ + readonly total_bounties_in_progress: number; + /** Total $FNDRY earned. */ + readonly total_fndry_earned: number; + /** Average AI review score across all submissions. */ + readonly average_review_score: number; + /** Number of submissions that were auto-approved. */ + readonly auto_approved_count: number; + /** Current tier unlocked. */ + readonly tier_unlocked: number; + /** Bounties completed per tier. */ + readonly bounties_by_tier: Record; + /** Total submissions made. */ + readonly total_submissions: number; + /** Submission approval rate (0.0 - 1.0). */ + readonly approval_rate: number; +} + +// --------------------------------------------------------------------------- +// Notification types +// --------------------------------------------------------------------------- + +/** A single notification for a user. */ +export interface UserNotification { + /** Unique notification UUID. */ + readonly id: string; + /** Type of notification (e.g., "submission_reviewed", "bounty_completed", "payout_sent"). */ + readonly notification_type: string; + /** Human-readable notification title. */ + readonly title: string; + /** Notification body text. */ + readonly body: string; + /** Whether the notification has been read. */ + readonly read: boolean; + /** URL to navigate to when clicked (if applicable). */ + readonly action_url: string | null; + /** ISO timestamp of the notification. */ + readonly created_at: string; +} + +/** Paginated notification list response. */ +export interface UserNotificationResponse { + /** Array of notifications. */ + readonly items: UserNotification[]; + /** Total number of notifications matching the query. */ + readonly total: number; + /** Number of unread notifications. */ + readonly unread_count: number; + /** Pagination offset. */ + readonly skip: number; + /** Page size. */ + readonly limit: number; +} + +/** Response after marking a single notification as read. */ +export interface UserNotificationUpdate { + /** The updated notification. */ + readonly notification: UserNotification; + /** Updated unread count. */ + readonly unread_count: number; +} + +/** User notification preferences. */ +export interface UserNotificationPreferences { + /** Whether to receive email notifications for submission reviews. */ + readonly email_on_review: boolean; + /** Whether to receive email notifications for bounty completions. */ + readonly email_on_completion: boolean; + /** Whether to receive email notifications for payouts. */ + readonly email_on_payout: boolean; + /** Whether to receive email notifications for new bounties. */ + readonly email_on_new_bounty: boolean; + /** Whether to receive in-app notifications. */ + readonly in_app_notifications: boolean; +} + +// --------------------------------------------------------------------------- +// Submission review types +// --------------------------------------------------------------------------- + +/** A single model's review result for a submission. */ +export interface ModelReviewResult { + /** Name of the reviewing model (e.g., "claude", "codex", "gemini"). */ + readonly model_name: string; + /** Score assigned by this model (0-10). */ + readonly score: number; + /** Summary of the model's review feedback. */ + readonly summary: string; + /** Detailed review comments from the model. */ + readonly detailed_feedback: string; + /** Whether the model found any critical issues. */ + readonly has_critical_issues: boolean; + /** List of specific issues found (if any). */ + readonly issues: string[]; + /** ISO timestamp when the review completed. */ + readonly reviewed_at: string; +} + +/** Complete AI review response for a submission. */ +export interface SubmissionReviewResponse { + /** UUID of the submission being reviewed. */ + readonly submission_id: string; + /** UUID of the parent bounty. */ + readonly bounty_id: string; + /** Aggregated AI review score across all models (0-10). */ + readonly aggregated_score: number; + /** Whether the score meets the tier threshold for approval. */ + readonly meets_threshold: boolean; + /** Whether the review process is complete for all models. */ + readonly review_complete: boolean; + /** Whether eligible for automatic approval. */ + readonly auto_approve_eligible: boolean; + /** Individual review results from each model. */ + readonly model_reviews: ModelReviewResult[]; + /** ISO timestamp when the review process started. */ + readonly reviewed_at: string; +} diff --git a/sdk/src/users.ts b/sdk/src/users.ts new file mode 100644 index 000000000..6ceafa57c --- /dev/null +++ b/sdk/src/users.ts @@ -0,0 +1,227 @@ +/** + * User resource module for the SolFoundry SDK. + * + * Provides methods for managing user profiles, viewing activity + * history, and retrieving contribution statistics. All user + * operations require authentication. + * + * @module users + */ + +import type { HttpClient } from './client.js'; +import type { + UserActivityResponse, + UserContributionStats, + UserNotificationPreferences, + UserProfile, + UserProfileUpdate, + UserNotificationResponse, + UserNotificationUpdate, +} from './types.js'; + +/** + * Client for interacting with the SolFoundry user API. + * + * Wraps `/api/users` and `/api/notifications` endpoints with + * type-safe methods. Requires authentication for all operations. + * + * @example + * ```typescript + * const users = new UserClient(httpClient); + * + * // Get the authenticated user's profile + * const profile = await users.getProfile(); + * console.log(profile.username, profile.reputation_score); + * + * // Update display name + * await users.updateProfile({ display_name: 'Alice' }); + * ``` + */ +export class UserClient { + private readonly http: HttpClient; + + /** + * Create a new UserClient. + * + * @param http - The configured HTTP client for API communication. + */ + constructor(http: HttpClient) { + this.http = http; + } + + /** + * Get the authenticated user's profile. + * + * Returns the full profile of the currently authenticated user, + * including reputation score, tier progression, and earned badges. + * + * @returns The authenticated user's profile. + * @throws {AuthenticationError} If not authenticated. + */ + async getProfile(): Promise { + return this.http.request({ + path: '/api/users/me', + method: 'GET', + requiresAuth: true, + }); + } + + /** + * Update the authenticated user's profile. + * + * Partially updates the user's profile. Only provided fields are + * updated; omitted fields remain unchanged. + * + * @param data - Fields to update (all optional). + * @returns The updated user profile. + * @throws {AuthenticationError} If not authenticated. + * @throws {ValidationError} If the update data is invalid. + */ + async updateProfile(data: UserProfileUpdate): Promise { + return this.http.request({ + path: '/api/users/me', + method: 'PATCH', + body: data, + requiresAuth: true, + }); + } + + /** + * Get the authenticated user's activity history. + * + * Returns a paginated list of recent activities including bounty + * submissions, reviews, payouts, and profile updates. + * + * @param options - Optional pagination parameters. + * @param options.skip - Pagination offset (default 0). + * @param options.limit - Page size (default 20, max 100). + * @returns Paginated activity history. + * @throws {AuthenticationError} If not authenticated. + */ + async getActivity(options?: { + skip?: number; + limit?: number; + }): Promise { + return this.http.request({ + path: '/api/users/me/activity', + method: 'GET', + params: { + skip: options?.skip, + limit: options?.limit, + }, + requiresAuth: true, + }); + } + + /** + * Get the authenticated user's contribution statistics. + * + * Returns aggregated statistics including total bounties completed, + * total $FNDRY earned, average review score, and tier progression. + * + * @returns Contribution statistics for the authenticated user. + * @throws {AuthenticationError} If not authenticated. + */ + async getContributionStats(): Promise { + return this.http.request({ + path: '/api/users/me/stats', + method: 'GET', + requiresAuth: true, + }); + } + + /** + * List notifications for the authenticated user. + * + * Returns a paginated list of notifications including bounty updates, + * submission reviews, and payout confirmations. + * + * @param options - Optional filtering and pagination. + * @param options.unread_only - Filter to only unread notifications. + * @param options.skip - Pagination offset (default 0). + * @param options.limit - Page size (default 20, max 100). + * @returns Paginated notification list. + * @throws {AuthenticationError} If not authenticated. + */ + async listNotifications(options?: { + unread_only?: boolean; + skip?: number; + limit?: number; + }): Promise { + return this.http.request({ + path: '/api/users/me/notifications', + method: 'GET', + params: { + unread_only: options?.unread_only, + skip: options?.skip, + limit: options?.limit, + }, + requiresAuth: true, + }); + } + + /** + * Mark a notification as read. + * + * @param notificationId - The UUID of the notification to mark as read. + * @returns The updated notification. + * @throws {NotFoundError} If the notification does not exist. + * @throws {AuthenticationError} If not authenticated. + */ + async markNotificationRead(notificationId: string): Promise { + return this.http.request({ + path: `/api/users/me/notifications/${notificationId}/read`, + method: 'POST', + requiresAuth: true, + }); + } + + /** + * Mark all notifications as read for the authenticated user. + * + * @returns Confirmation of the bulk update. + * @throws {AuthenticationError} If not authenticated. + */ + async markAllNotificationsRead(): Promise<{ success: boolean }> { + return this.http.request<{ success: boolean }>({ + path: '/api/users/me/notifications/read-all', + method: 'POST', + requiresAuth: true, + }); + } + + /** + * Get the authenticated user's notification preferences. + * + * Returns which notification types are enabled (email, in-app, etc.). + * + * @returns Current notification preferences. + * @throws {AuthenticationError} If not authenticated. + */ + async getNotificationPreferences(): Promise { + return this.http.request({ + path: '/api/users/me/notifications/preferences', + method: 'GET', + requiresAuth: true, + }); + } + + /** + * Update the authenticated user's notification preferences. + * + * @param data - Preference fields to update (all optional). + * @returns Updated notification preferences. + * @throws {AuthenticationError} If not authenticated. + * @throws {ValidationError} If the preference data is invalid. + */ + async updateNotificationPreferences( + data: Partial, + ): Promise { + return this.http.request({ + path: '/api/users/me/notifications/preferences', + method: 'PATCH', + body: data, + requiresAuth: true, + }); + } +} \ No newline at end of file