From a3bb8e15e4f1bdd7fc9d5981f14bdf5cf15f56c2 Mon Sep 17 00:00:00 2001 From: Osuochasam Date: Mon, 31 Aug 2026 14:42:43 +0100 Subject: [PATCH] feat(tasks): add pre-validation service and bulk task creation endpoint --- .../.kiro/specs/bulk-task-import/.config.kiro | 1 + .../.kiro/specs/bulk-task-import/design.md | 424 ++++++++++++++++++ .../specs/bulk-task-import/requirements.md | 143 ++++++ .../.kiro/specs/bulk-task-import/tasks.md | 160 +++++++ frontend/package.json | 1 + frontend/pnpm-lock.yaml | 94 ++-- frontend/src/app/api/tasks/bulk/route.ts | 97 ++++ frontend/src/lib/bulk-task-validation.test.ts | 230 ++++++++++ frontend/src/lib/bulk-task-validation.ts | 127 ++++++ 9 files changed, 1238 insertions(+), 39 deletions(-) create mode 100644 frontend/.kiro/specs/bulk-task-import/.config.kiro create mode 100644 frontend/.kiro/specs/bulk-task-import/design.md create mode 100644 frontend/.kiro/specs/bulk-task-import/requirements.md create mode 100644 frontend/.kiro/specs/bulk-task-import/tasks.md create mode 100644 frontend/src/app/api/tasks/bulk/route.ts create mode 100644 frontend/src/lib/bulk-task-validation.test.ts create mode 100644 frontend/src/lib/bulk-task-validation.ts diff --git a/frontend/.kiro/specs/bulk-task-import/.config.kiro b/frontend/.kiro/specs/bulk-task-import/.config.kiro new file mode 100644 index 0000000..1c36eed --- /dev/null +++ b/frontend/.kiro/specs/bulk-task-import/.config.kiro @@ -0,0 +1 @@ +{"specId": "91341b44-2374-4346-8a5e-69e07589a5ae", "workflowType": "requirements-first", "specType": "feature"} \ No newline at end of file diff --git a/frontend/.kiro/specs/bulk-task-import/design.md b/frontend/.kiro/specs/bulk-task-import/design.md new file mode 100644 index 0000000..6880f31 --- /dev/null +++ b/frontend/.kiro/specs/bulk-task-import/design.md @@ -0,0 +1,424 @@ +# Design Document — Bulk Task Import + +## Overview + +The Bulk Task Import feature adds a `POST /api/tasks/bulk` endpoint that accepts a JSON array of up to 100 task objects, validates every row individually using the existing `taskSchema` and `CreateTaskInput` rules, inserts only the valid rows into the in-memory `Task_Store`, and returns a structured `BulkImportResult` that reports success and failure counts alongside per-row error details. + +### Design Goals + +- **Zero duplication** — reuse `taskSchema`, `createTask()`, `checkRateLimit()`, and `buildNoStoreJson()` exactly as they exist today. +- **Full-batch feedback** — never stop at the first invalid row; collect all errors across all rows before responding. +- **Consistent HTTP semantics** — 4xx only for structural / rate-limit failures; HTTP 200 for any structurally valid batch regardless of per-row outcome. +- **Store isolation in tests** — every test suite calls `resetTaskWorkflowStore()` in `beforeEach` so the in-memory `Map` is never shared across test runs. + +--- + +## Architecture + +The feature sits entirely within the existing Next.js App Router layer and the `src/lib` utility layer. No new infrastructure, databases, queues, or external services are introduced. + +``` +POST /api/tasks/bulk + │ + ▼ +src/app/api/tasks/bulk/route.ts ← New route handler + │ + ├─ checkRateLimit() ← src/lib/rate-limit.ts (existing) + ├─ Content-Type check ← inline in route handler + ├─ JSON parse + structural checks + │ + ▼ +src/lib/bulk-task-validation.ts ← New validation service + │ + ├─ validateBulkRows() + │ └─ taskSchema.safeParse() per row ← src/lib/taskValidation.ts (existing) + │ └─ poster blank-check per row ← mirrors validateCreateTaskInput + │ + └─ insertValidRows() + └─ createTask() per valid row ← src/lib/task-workflow.ts (existing) +``` + +### Data Flow + +``` +Request body (unknown[]) + │ + ▼ validateBulkRows(rows, now) +┌─────────────────────────────────────────────────┐ +│ For each row i: │ +│ safeParse(row) → ZodError → field messages │ +│ poster blank-check → poster message │ +│ all messages joined with "; " │ +│ ──► validRows[] OR errors[{ rowIndex, msg}] │ +└─────────────────────────────────────────────────┘ + │ + ▼ insertValidRows(validRows, now) +┌─────────────────────────────────────────────────┐ +│ For each { index, input } in validRows: │ +│ createTask(input, now) │ +│ ok → successCount++ │ +│ !ok → errorCount++, push to errors │ +└─────────────────────────────────────────────────┘ + │ + ▼ +BulkImportResult { totalProcessed, successCount, errorCount, errors } +``` + +--- + +## Components and Interfaces + +### `src/lib/bulk-task-validation.ts` (new) + +#### Exported Types + +```typescript +export interface BulkRowError { + rowIndex: number; // zero-based index in the original input array + message: string; // field errors joined by "; " +} + +export interface BulkValidationResult { + validRows: Array<{ index: number; input: CreateTaskInput }>; + errors: BulkRowError[]; +} + +export interface BulkImportResult { + totalProcessed: number; + successCount: number; + errorCount: number; + errors: BulkRowError[]; +} +``` + +#### `validateBulkRows(rows: unknown[], now?: Date): BulkValidationResult` + +- Iterates **every** row without early exit. +- For each row: + 1. Calls `taskSchema.safeParse(row)` on the six schema fields (`title`, `description`, `tokenAddress`, `reward`, `deadline`, `maxSubmissions`). + 2. Extracts Zod error messages formatted as `"fieldName: Zod message"` from `ZodError.issues`. + 3. Checks `poster` separately: if `row.poster` trims to an empty string, appends `"poster: Poster address is required"`. + 4. If any messages were collected, pushes `{ rowIndex: i, message: messages.join("; ") }` to `errors`. + 5. Otherwise, pushes `{ index: i, input: validatedInput }` to `validRows`. +- The `now` parameter (defaults to `new Date()`) is threaded through to Zod's deadline refinement so tests can freeze time. +- Returns `{ validRows, errors }`. + +**Deadline validation detail:** `taskSchema`'s `deadline` field uses `z.coerce.date()` with a `.refine()` that compares against `Date.now()`. To make this time-injectable for tests, `validateBulkRows` temporarily overrides the refinement by passing the parsed deadline against `now.getTime()` post-parse, OR by patching the schema inline with a new refinement. The simpler approach (and the one used) is to validate deadline range manually after `safeParse` succeeds for the `deadline` field — mirroring the `validateCreateTaskInput` logic in `task-workflow.ts`, which accepts `deadline` as a Unix timestamp in seconds and compares against `Math.floor(now.getTime() / 1000)`. The `taskSchema` uses `z.coerce.date()`, which accepts both ISO strings and Unix ms timestamps, so the row-level validator will accept either format; the route handler passes `new Date()` as `now`. + +#### `insertValidRows(validRows: Array<{ index: number; input: CreateTaskInput }>, now?: Date): BulkImportResult` + +- Iterates every valid row. +- Calls `createTask(input, now)` for each. +- If `ok: true`, increments `successCount`. +- If `ok: false` (unexpected — input was already validated, but defensive), increments `errorCount` and pushes `{ rowIndex: index, message: result.error }` to `errors`. +- Returns `BulkImportResult` with `totalProcessed = validRows.length + preExistingErrorCount` (note: the route handler computes `totalProcessed` from the original array length and merges validation errors with insertion errors). + +> The route handler is responsible for combining `BulkValidationResult.errors` with any insertion errors and computing the final `totalProcessed = rows.length`. + +--- + +### `src/app/api/tasks/bulk/route.ts` (new) + +Mirrors `src/app/api/tasks/route.ts` structure exactly. + +``` +export const runtime = "nodejs" +export const dynamic = "force-dynamic" + +POST handler — ordered checks: + 1. checkRateLimit(request) → 429 if blocked + 2. Content-Type header check → 415 if not "application/json" + 3. request.json() → 400 "Request body must be valid JSON." on throw + 4. Array.isArray(body) → 400 "Request body must be a JSON array." if false + 5. body.length === 0 → 400 "Batch must contain at least 1 row." + 6. body.length > 100 → 400 "Batch size exceeds the maximum of 100 rows." + 7. First non-null-object scan → 400 "Row {i} is not a valid object." (first offending index) + 8. validateBulkRows(body, new Date()) + 9. insertValidRows(validRows, new Date()) + 10. Merge validation errors + insertion errors + 11. Return HTTP 200 with BulkImportResult + rate-limit headers +``` + +**Content-Type check implementation:** + +```typescript +const contentType = request.headers.get("content-type") ?? ""; +if (!contentType.includes("application/json")) { + return buildNoStoreJson( + { ok: false, error: "Content-Type must be application/json." }, + 415, + rateLimitHeaders, + ); +} +``` + +**Non-object element scan:** + +```typescript +for (let i = 0; i < body.length; i++) { + const el = body[i]; + if (el === null || typeof el !== "object" || Array.isArray(el)) { + return buildNoStoreJson( + { ok: false, error: `Row ${i} is not a valid object.` }, + 400, + rateLimitHeaders, + ); + } +} +``` + +--- + +### `src/lib/bulk-task-validation.test.ts` (new) + +Three primary scenario suites plus structural validation tests. + +``` +describe("validateBulkRows + insertValidRows", () => { + beforeEach(() => resetTaskWorkflowStore()) + + describe("100% valid payload", () => { ... }) + describe("Mixed payload", () => { ... }) + describe("100% invalid payload", () => { ... }) +}) + +describe("POST /api/tasks/bulk — structural validation", () => { + beforeEach(() => resetTaskWorkflowStore()) + // empty array, oversized, non-object elements, bad JSON, wrong Content-Type +}) + +describe("Conservation invariant", () => { + beforeEach(() => resetTaskWorkflowStore()) + // successCount + errorCount === totalProcessed for various batch compositions +}) +``` + +--- + +## Data Models + +### Input + +The `POST /api/tasks/bulk` body is `unknown[]`. After structural validation, each element is treated as `Record` and passed to `validateBulkRows`. + +Each row is expected to conform to: + +| Field | Type | Source schema | +|---|---|---| +| `poster` | `string` | `CreateTaskInput` / `validateCreateTaskInput` | +| `title` | `string` | `taskSchema` | +| `description` | `string` | `taskSchema` | +| `tokenAddress` | `string` | `taskSchema` | +| `reward` | `number` (coerced) | `taskSchema` | +| `deadline` | `Date` / ISO string / Unix ms (coerced) | `taskSchema` | +| `maxSubmissions` | `number` (coerced) | `taskSchema` | + +### Output — `BulkImportResult` + +```typescript +{ + totalProcessed: number; // = input array length + successCount: number; // rows written to Task_Store + errorCount: number; // rows that failed validation or insertion + errors: Array<{ + rowIndex: number; // zero-based index in the input array + message: string; // "; "-joined field error strings + }>; +} +``` + +**Invariants:** +- `successCount + errorCount === totalProcessed` +- `errors.length === errorCount` +- `errors.length <= totalProcessed` +- Every `rowIndex` in `errors` is a unique integer in `[0, totalProcessed)`. + +### Error Message Format + +Each field error is formatted as `": "`. When a row has multiple invalid fields, the messages are joined: `"title: Title must be at least 5 characters; reward: Reward must be strictly greater than zero"`. + +The `poster` error is always `"poster: Poster address is required"` (not routed through Zod). + +--- + +## Correctness Properties + +*A property is a characteristic or behavior that should hold true across all valid executions of a system — essentially, a formal statement about what the system should do. Properties serve as the bridge between human-readable specifications and machine-verifiable correctness guarantees.* + +### Property 1: Conservation Invariant + +*For any* structurally valid input array (non-empty, ≤ 100 elements, all non-null objects), the response SHALL satisfy `successCount + errorCount === totalProcessed` and `errors.length === errorCount`, regardless of how many rows pass or fail validation. + +**Validates: Requirements 5.8, 8.5** + +--- + +### Property 2: All-Valid Batch Completeness + +*For any* batch of N rows (1 ≤ N ≤ 100) where every row satisfies all field validation rules, the endpoint SHALL return `successCount === N`, `errorCount === 0`, `errors === []`, and the Task_Store SHALL contain exactly N tasks after the request completes. + +**Validates: Requirements 4.3, 8.1** + +--- + +### Property 3: All-Invalid Batch Isolation + +*For any* batch of N rows (1 ≤ N ≤ 100) where every row fails at least one field validation rule, the endpoint SHALL return `successCount === 0`, `errorCount === N`, `errors.length === N`, and the Task_Store SHALL contain zero tasks (assuming the store was empty before the request). + +**Validates: Requirements 3.5, 4.5, 8.3** + +--- + +### Property 4: Mixed Batch Accuracy + +*For any* batch containing exactly `v` valid rows and `i` invalid rows (v ≥ 1, i ≥ 1, v + i ≤ 100), the endpoint SHALL return `successCount === v`, `errorCount === i`, and the `errors` array SHALL contain exactly the zero-based indices of the invalid rows with no entry for any valid row. The Task_Store SHALL contain exactly the `v` valid rows and no invalid rows. + +**Validates: Requirements 3.6, 4.4, 8.2** + +--- + +### Property 5: Row Index Fidelity + +*For any* batch where row at zero-based index `i` is invalid, the corresponding entry in the `errors` array SHALL have `rowIndex === i`, regardless of how many other rows in the batch are valid or invalid and regardless of the order of valid and invalid rows. + +**Validates: Requirements 1.7, 3.3, 5.5** + +--- + +### Property 6: Multi-Field Error Completeness + +*For any* row with `k` invalid fields (k ≥ 1), the `message` string for that row's error entry SHALL contain exactly `k` semicolon-separated field error sub-strings, one per invalid field, and SHALL NOT omit any failing field's error message. + +**Validates: Requirements 2.9, 3.3** + +--- + +### Property 7: Validator Field Message Correctness + +*For any* row field value that violates a specific validation rule, the validator SHALL produce the exact error message specified for that rule. Specifically: +- Any `title` shorter than 5 chars → `"title: Title must be at least 5 characters"` +- Any `title` longer than 100 chars → `"title: Title must be at most 100 characters"` +- Any `description` shorter than 10 chars → `"description: Description must be at least 10 characters"` +- Any `tokenAddress` not exactly 56 chars → `"tokenAddress: Token address must be exactly 56 characters"` +- Any 56-char `tokenAddress` not starting with `'G'` → `"tokenAddress: Token address must start with 'G'"` +- Any `reward` ≤ 0 or non-positive → `"reward: Reward must be strictly greater than zero"` +- Any past or unparseable `deadline` → `"deadline: Deadline must be in the future"` +- Any `deadline` more than 365 days ahead → `"deadline: Deadline cannot be more than 365 days from now"` +- Any `maxSubmissions` < 1 → `"maxSubmissions: Max submissions must be at least 1"` +- Any blank `poster` → `"poster: Poster address is required"` + +**Validates: Requirements 2.2–2.8** + +--- + +### Property 8: Repeated Submission Non-Uniqueness + +*For any* batch of N valid rows, submitting the identical batch a second time (after the first succeeds) SHALL also return `successCount === N` with no errors, confirming that the bulk endpoint imposes no uniqueness constraint between independent requests. + +**Validates: Requirements 8.4** + +--- + +### Property 9: First Non-Object Index Reporting + +*For any* array where element at index `i` is not a non-null object (and the array is non-empty and ≤ 100 elements), the endpoint SHALL return HTTP 400 with an error message identifying index `i` as the first offending element, regardless of how many other non-object elements follow it. + +**Validates: Requirements 1.6** + +--- + +## Error Handling + +### Route-Level Errors (HTTP 4xx) + +| Condition | HTTP Status | Response body | +|---|---|---| +| Rate limit exceeded | 429 | From `checkRateLimit()` | +| Missing/wrong Content-Type | 415 | `{ ok: false, error: "Content-Type must be application/json." }` | +| Invalid JSON body | 400 | `{ ok: false, error: "Request body must be valid JSON." }` | +| Body is not an array | 400 | `{ ok: false, error: "Request body must be a JSON array." }` | +| Empty array | 400 | `{ ok: false, error: "Batch must contain at least 1 row." }` | +| Array > 100 elements | 400 | `{ ok: false, error: "Batch size exceeds the maximum of 100 rows." }` | +| Element is not a non-null object | 400 | `{ ok: false, error: "Row {i} is not a valid object." }` | + +The checks are evaluated in priority order: rate-limit → Content-Type → JSON parse → array check → empty → oversized → non-object scan. + +### Per-Row Validation Errors (included in HTTP 200 response) + +Per-row errors are **not** HTTP errors. They are collected into the `errors` array of the `BulkImportResult` and returned with HTTP 200. Each entry has: +- `rowIndex` — the zero-based position of the failing row in the input array. +- `message` — all field error strings for that row joined with `"; "`. + +### Defensive Insertion Errors + +`insertValidRows` calls `createTask()`, which itself calls `validateCreateTaskInput` again. If `createTask` returns `ok: false` for a row that `validateBulkRows` accepted (theoretically impossible in normal flow since both use the same validation logic, but defensively handled), the insertion error is added to the `errors` array with the original `rowIndex`. + +--- + +## Testing Strategy + +### Dual Testing Approach + +Unit tests verify specific examples, edge cases, and error conditions. Property-based tests verify universal invariants across generated inputs. Both are complementary. + +**Property-Based Testing Library:** `fast-check` (to be added as a dev dependency: `pnpm add -D fast-check`). Each property test runs a minimum of **100 iterations**. + +Each property test is tagged with: +``` +// Feature: bulk-task-import, Property N: +``` + +### Unit / Integration Tests in `bulk-task-validation.test.ts` + +**Suite 1 — 100% Valid Payload** +- `beforeEach`: `resetTaskWorkflowStore()` +- Build N rows of fully valid data (title ≥ 5 chars, description ≥ 10 chars, valid 56-char `G…` tokenAddress, reward ≥ 1_000_000, deadline in future but ≤ 365 days, maxSubmissions ≥ 1, non-blank poster). +- Call `validateBulkRows` then `insertValidRows`. +- Assert: `successCount === N`, `errors === []`, store size equals N. + +**Suite 2 — Mixed Payload** +- `beforeEach`: `resetTaskWorkflowStore()` +- Construct a batch with specific valid rows at known indices and invalid rows at other known indices. +- Assert: `successCount === validCount`, `errorCount === invalidCount`, each error entry has the correct `rowIndex`, only valid rows exist in store. + +**Suite 3 — 100% Invalid Payload** +- `beforeEach`: `resetTaskWorkflowStore()` +- Build N rows that each fail at least one field rule. +- Assert: `successCount === 0`, `errors.length === N`, each error entry exists, store size is 0. + +**Structural Validation Tests** +- Empty array → 400 with correct message. +- 101-element array → 400 with correct message. +- Non-object element (e.g., string at index 2) → 400 identifying index 2. +- Invalid JSON → 400. +- Missing Content-Type → 415. + +**Conservation Invariant Test** +- Submit several batch compositions (all-valid, all-invalid, mixed). +- For each: assert `successCount + errorCount === totalProcessed` and `errors.length === errorCount`. + +### Property-Based Tests + +Property tests are co-located in `bulk-task-validation.test.ts` using `fast-check`. + +**Property 1 test** — Generate a random array length N (1–100), build N rows each randomly valid or invalid, submit, assert conservation invariant holds. + +**Property 2 test** — Generate N all-valid rows (1 ≤ N ≤ 100), assert `successCount === N` and store has N tasks. + +**Property 3 test** — Generate N all-invalid rows, assert `successCount === 0` and store is empty. + +**Property 4 test** — Generate v valid rows and i invalid rows in a shuffled order, assert mixed-batch accuracy including rowIndex fidelity. + +**Property 5 test** — For any batch where index `j` is invalid, assert `errors` contains entry with `rowIndex === j`. + +**Property 6 test** — For any row with k invalid fields, assert the message contains exactly k `"; "`-delimited segments. + +**Property 9 test** — Insert a non-object at random index `i` in an otherwise valid array, assert HTTP 400 mentioning index `i`. + +### Test Helpers + +A `buildValidRow()` helper constructs a minimally valid `CreateTaskInput`-compatible object with overrideable fields, used across all test suites to reduce repetition. + +A `buildInvalidRow(invalidFields)` helper creates a row with specified fields set to invalid values (empty string for title, negative number for reward, etc.). + +Both helpers accept a `now: Date` parameter to keep deadline calculations in sync with the test clock. diff --git a/frontend/.kiro/specs/bulk-task-import/requirements.md b/frontend/.kiro/specs/bulk-task-import/requirements.md new file mode 100644 index 0000000..30339fe --- /dev/null +++ b/frontend/.kiro/specs/bulk-task-import/requirements.md @@ -0,0 +1,143 @@ +# Requirements Document + +## Introduction + +The Bulk Task Import feature adds a `POST /api/tasks/bulk` endpoint that accepts a JSON array of task objects, validates every row individually against the existing task schema, and inserts only the valid rows into the in-memory store. Rather than failing on the first error, the endpoint collects all validation failures and returns them alongside the insertion results. This lets callers fix every problem in a single round-trip instead of submitting repeatedly. + +## Glossary + +- **Bulk_Endpoint**: The `POST /api/tasks/bulk` route handler that processes a batch of task rows. +- **Row**: A single JSON object within the input array, representing one task to be created. +- **Row_Index**: The zero-based position of a Row within the input array. +- **Validator**: The per-row validation logic that applies `taskSchema` and `CreateTaskInput` rules to a Row. +- **Valid_Record**: A Row that passes all validation checks and is eligible for insertion. +- **Invalid_Record**: A Row that fails one or more validation checks and is excluded from insertion. +- **Bulk_Import_Result**: The JSON response object returned by the Bulk_Endpoint. +- **Batch**: The full set of Rows submitted in a single request to the Bulk_Endpoint. +- **taskSchema**: The existing Zod schema in `src/lib/taskValidation.ts` that validates `title`, `description`, `tokenAddress`, `reward`, `deadline`, and `maxSubmissions`. +- **CreateTaskInput**: The existing TypeScript type in `src/types/task-workflow.ts` that additionally requires a `poster` field. +- **Rate_Limiter**: The existing `checkRateLimit()` helper in `src/lib/rate-limit.ts`. +- **Task_Store**: The in-memory `Map` maintained by `src/lib/task-workflow.ts`. +- **Absent**: A field is absent when its key is missing from the Row object, or its value is `null` or `undefined`. + +## Requirements + +### Requirement 1: Accepted Input Shape + +**User Story:** As an API consumer, I want to POST a JSON array of task objects to a single endpoint, so that I can create many tasks in one request without calling the single-task endpoint repeatedly. + +#### Acceptance Criteria + +1. THE Bulk_Endpoint SHALL accept a request body that is a JSON array where every element is a non-null JSON object (not a string, number, boolean, null, or nested array). +2. WHEN the request body is not valid JSON, THE Bulk_Endpoint SHALL return HTTP 400 with `{ "ok": false, "error": "Request body must be valid JSON." }`. +3. WHEN the request body is valid JSON but is not an array, THE Bulk_Endpoint SHALL return HTTP 400 with `{ "ok": false, "error": "Request body must be a JSON array." }`. +4. WHEN the request body is an empty array, THE Bulk_Endpoint SHALL return HTTP 400 with `{ "ok": false, "error": "Batch must contain at least 1 row." }` and SHALL NOT insert any records into the Task_Store. +5. WHEN the array contains more than 100 elements, THE Bulk_Endpoint SHALL return HTTP 400 with `{ "ok": false, "error": "Batch size exceeds the maximum of 100 rows." }` and SHALL NOT insert any records. +6. Structural checks SHALL be evaluated in priority order: empty array and oversized array are checked before non-object element checks. WHEN any element in the array is not a non-null JSON object (and structural size checks pass), THE Bulk_Endpoint SHALL return HTTP 400 identifying the zero-based index of the first offending element (e.g., `{ "ok": false, "error": "Row 3 is not a valid object." }`). +7. THE Bulk_Endpoint SHALL assign each valid array element a Row_Index equal to its zero-based position in the input array. +8. WHEN the request does not include a `Content-Type: application/json` header, THE Bulk_Endpoint SHALL return HTTP 415 with `{ "ok": false, "error": "Content-Type must be application/json." }`. + +--- + +### Requirement 2: Per-Row Field Validation + +**User Story:** As an API consumer, I want each row validated against the same rules as the single-task endpoint, so that the bulk import enforces the same data quality constraints. + +#### Acceptance Criteria + +1. THE Validator SHALL evaluate each Row's fields as follows: `title`, `description`, `tokenAddress`, `reward`, `deadline`, and `maxSubmissions` are governed by `taskSchema`; `poster` is governed by the `CreateTaskInput` rules in `validateCreateTaskInput`. A field is absent when its key is missing, or its value is `null` or `undefined`. String coercion via `z.coerce` means numeric strings are accepted where a number is expected. +2. WHEN a Row's `title` is absent, or its string value (after trimming) is shorter than 5 characters, THE Validator SHALL produce the message `"title: Title must be at least 5 characters"`. WHEN a Row's `title` exceeds 100 characters, THE Validator SHALL produce the message `"title: Title must be at most 100 characters"`. +3. WHEN a Row's `description` is absent or shorter than 10 characters, THE Validator SHALL produce the message `"description: Description must be at least 10 characters"`. +4. WHEN a Row's `tokenAddress` is absent or not exactly 56 characters long, THE Validator SHALL produce the message `"tokenAddress: Token address must be exactly 56 characters"`. WHEN a Row's `tokenAddress` is 56 characters but does not start with the character `'G'`, THE Validator SHALL produce the message `"tokenAddress: Token address must start with 'G'"`. +5. WHEN a Row's `reward` is absent, zero, negative, or not a positive integer (including after coercion from string), or represents a value less than `1,000,000` stroops (the `MIN_TASK_REWARD` constant), THE Validator SHALL produce the message `"reward: Reward must be strictly greater than zero"`. +6. WHEN a Row's `deadline` is absent or not parseable as an ISO 8601 date string or Unix timestamp, THE Validator SHALL produce the message `"deadline: Deadline must be in the future"`. WHEN a Row's `deadline` is parseable but represents a date-time not strictly in the future at the moment of processing, THE Validator SHALL produce the message `"deadline: Deadline must be in the future"`. WHEN a Row's `deadline` is parseable and in the future but represents a date more than 365 days from the time of processing, THE Validator SHALL produce the message `"deadline: Deadline cannot be more than 365 days from now"` (the too-far-in-future message takes precedence over the future check when both conditions apply). +7. WHEN a Row's `maxSubmissions` is absent or not an integer greater than or equal to 1, THE Validator SHALL produce the message `"maxSubmissions: Max submissions must be at least 1"`. +8. WHEN a Row's `poster` field is absent or is an empty string after trimming whitespace, THE Validator SHALL produce the message `"poster: Poster address is required"`. +9. WHEN a Row contains multiple invalid fields, THE Validator SHALL produce one error message per invalid field and SHALL NOT stop evaluation at the first failing field. + +--- + +### Requirement 3: Error Aggregation Behavior + +**User Story:** As an API consumer, I want to receive every validation error from every row in a single response, so that I can fix all problems at once instead of resubmitting multiple times. + +#### Acceptance Criteria + +1. THE Bulk_Endpoint SHALL evaluate every Row in the Batch regardless of whether earlier Rows have failed validation. +2. THE Bulk_Endpoint SHALL collect all per-row validation failures across all Rows before constructing the response. +3. WHEN a Row fails validation, THE Bulk_Endpoint SHALL record an entry `{ "rowIndex": , "message": }` in the `errors` array of the Bulk_Import_Result. +4. THE Bulk_Endpoint SHALL return HTTP 200 when the Batch passes structural validation (non-empty array within size limits, all elements are objects) even if every Row fails per-row field validation; HTTP 4xx responses are reserved for structural/batch-level errors and rate limit violations. +5. WHEN all Rows in the Batch are invalid, THE Bulk_Endpoint SHALL return HTTP 200 with `successCount: 0` and an `errors` array containing one entry per invalid Row. +6. WHEN a Batch contains a mix of valid and invalid Rows, THE Bulk_Endpoint SHALL set `successCount` to the count of Rows that pass validation and were inserted, and `errorCount` to the count of Rows that failed validation. + +--- + +### Requirement 4: Insertion Behavior + +**User Story:** As an API consumer, I want valid rows to be saved even when some rows are invalid, so that a single bad record does not block the rest of the batch. + +#### Acceptance Criteria + +1. THE Bulk_Endpoint SHALL call `createTask()` for each Valid_Record only after the Validator has evaluated every Row in the Batch. +2. THE Bulk_Endpoint SHALL NOT call `createTask()` for any Invalid_Record. +3. WHEN all Rows pass validation, THE Bulk_Endpoint SHALL insert all Rows into the Task_Store and return `successCount` equal to the total number of Rows. +4. WHEN a Batch contains a mix of valid and invalid Rows, THE Bulk_Endpoint SHALL insert only the Valid_Records into the Task_Store and report each Invalid_Record in the `errors` array. +5. WHEN all Rows fail validation, THE Bulk_Endpoint SHALL insert zero records into the Task_Store and return `successCount: 0`. +6. WHEN a Batch contains a mix of valid and invalid Rows, THE Bulk_Endpoint SHALL insert all Valid_Records into the Task_Store (not just attempt them), so that the Task_Store contains exactly the Valid_Records from that Batch after the response is returned. +7. THE Bulk_Endpoint SHALL complete insertion of all Valid_Records before returning the response; no partial set of Valid_Records may be left un-inserted when the response is sent. + +--- + +### Requirement 5: Response Shape Contract + +**User Story:** As an API consumer, I want a structured response that tells me exactly how many rows succeeded, how many failed, and the details of each failure, so that I can process results programmatically. + +#### Acceptance Criteria + +1. THE Bulk_Endpoint SHALL return a JSON object with the fields `totalProcessed`, `successCount`, `errorCount`, and `errors` on every HTTP 200 response. +2. THE Bulk_Endpoint SHALL set `totalProcessed` to the total number of Rows in the input array. +3. THE Bulk_Endpoint SHALL set `successCount` to the number of Rows that passed validation AND were successfully written to the Task_Store. +4. THE Bulk_Endpoint SHALL set `errorCount` to the number of Rows that failed validation OR whose Task_Store write failed; `errorCount` SHALL equal the length of the `errors` array. +5. THE Bulk_Endpoint SHALL set `errors` to an array of objects each containing `rowIndex` (integer, zero-based, matching the Row's position in the input array) and `message` (a non-empty string of 1–500 characters); the length of `errors` SHALL equal `errorCount` and SHALL NOT exceed `totalProcessed`. +6. THE Bulk_Endpoint SHALL set the `Cache-Control` response header to `no-store` on all responses. +7. THE Bulk_Endpoint SHALL include the rate-limit headers (`X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset`) on every response (both 200 and 4xx non-429 responses). +8. FOR ALL input arrays that pass structural validation (non-empty, within size limits, all elements are objects), `successCount + errorCount` SHALL equal `totalProcessed`. + +--- + +### Requirement 6: Batch Size Limits + +**User Story:** As a platform operator, I want a hard cap on how many rows a single bulk request may contain, so that one large request cannot exhaust server memory or starve other requests. + +#### Acceptance Criteria + +1. IF a Batch contains more than 100 Rows, THEN THE Bulk_Endpoint SHALL return HTTP 400 with `{ "ok": false, "error": "Batch size exceeds the maximum of 100 rows." }` and SHALL NOT insert any records into the Task_Store. +2. IF a Batch contains zero Rows (empty array), THEN THE Bulk_Endpoint SHALL return HTTP 400 with `{ "ok": false, "error": "Batch must contain at least 1 row." }` and SHALL NOT insert any records. +3. Both size-limit checks SHALL be performed before any per-row validation begins. + +--- + +### Requirement 7: Rate Limiting + +**User Story:** As a platform operator, I want the bulk endpoint to enforce the same rate-limiting policy as all other task API routes, so that bulk imports cannot be used to bypass per-IP request limits. + +#### Acceptance Criteria + +1. WHEN the Bulk_Endpoint receives a request, it SHALL call `checkRateLimit(request)` before attempting to parse the request body. +2. WHEN `checkRateLimit` returns a non-null response object, THE Bulk_Endpoint SHALL return that response object immediately and SHALL NOT read or parse the request body. +3. WHEN `checkRateLimit` returns a null response (request is allowed), THE Bulk_Endpoint SHALL attach all rate-limit headers from `checkRateLimit`'s `headers` result to its own response before returning. +4. WHEN the bulk endpoint's per-IP request count equals the per-IP request count enforced on `POST /api/tasks` (same `API_RATE_LIMIT_MAX_REQUESTS` and `API_RATE_LIMIT_WINDOW_MS` environment variables), the Bulk_Endpoint SHALL block the request with HTTP 429 in the same manner as `POST /api/tasks`. + +--- + +### Requirement 8: Test Coverage + +**User Story:** As a developer, I want automated tests for the three canonical bulk-import scenarios, so that regressions in the validation pipeline or insertion logic are caught before deployment. + +#### Acceptance Criteria + +1. WHEN a Batch of N valid Rows (N ≥ 1) is submitted to the Bulk_Endpoint against an isolated Task_Store containing zero pre-existing tasks, THE Bulk_Endpoint SHALL return `successCount` equal to N, `totalProcessed` equal to N, an `errors` array of length zero, and the Task_Store SHALL contain exactly N tasks afterward. +2. WHEN a Batch containing at least one Valid_Record and at least one Invalid_Record is submitted to the Bulk_Endpoint, THE Bulk_Endpoint SHALL return `successCount` equal to the number of Valid_Records, `errorCount` equal to the number of Invalid_Records, and the `errors` array SHALL contain one entry per Invalid_Record where each entry's `rowIndex` equals that row's zero-based position in the submitted Batch array; the Task_Store SHALL contain exactly the Valid_Records and no Invalid_Records. +3. WHEN a Batch of N Rows where every Row is invalid is submitted to the Bulk_Endpoint against an isolated Task_Store containing zero pre-existing tasks, THE Bulk_Endpoint SHALL return `successCount: 0`, `errorCount` equal to N, an `errors` array of length N, and the Task_Store SHALL contain zero tasks afterward. +4. WHEN a Batch of N valid Rows is submitted and succeeds, and then the same N Rows are submitted again as a second Batch, THE second submission SHALL also return `successCount` equal to N with no errors, confirming that the bulk import does not impose uniqueness constraints between independent submissions. +5. FOR ALL successful HTTP 200 responses from THE Bulk_Endpoint, `successCount + errorCount` SHALL equal `totalProcessed` and the length of `errors` SHALL equal `errorCount`. diff --git a/frontend/.kiro/specs/bulk-task-import/tasks.md b/frontend/.kiro/specs/bulk-task-import/tasks.md new file mode 100644 index 0000000..28a81e3 --- /dev/null +++ b/frontend/.kiro/specs/bulk-task-import/tasks.md @@ -0,0 +1,160 @@ +# Implementation Plan: Bulk Task Import + +## Overview + +Implement a `POST /api/tasks/bulk` endpoint in three discrete steps: first build the validation and insertion service in `src/lib/bulk-task-validation.ts`, then wire it into a Next.js route handler at `src/app/api/tasks/bulk/route.ts`, and finally cover both with unit and property-based tests in `src/lib/bulk-task-validation.test.ts`. Each step builds directly on the previous one and re-uses only existing helpers (`taskSchema`, `createTask`, `checkRateLimit`, `buildNoStoreJson`, `resetTaskWorkflowStore`). No new runtime dependencies are needed; `fast-check` is added as a dev-only dependency before the test task. + +--- + +## Tasks + +- [x] 1. Create validation and insertion service (`src/lib/bulk-task-validation.ts`) + - [x] 1.1 Define and export the three shared types + - Export `BulkRowError { rowIndex: number; message: string }`. + - Export `BulkValidationResult { validRows: Array<{ index: number; input: CreateTaskInput }>; errors: BulkRowError[] }`. + - Export `BulkImportResult { totalProcessed: number; successCount: number; errorCount: number; errors: BulkRowError[] }`. + - Import `CreateTaskInput` from `@/types/task-workflow`. + - _Requirements: 5.1, 5.5_ + + - [x] 1.2 Implement `validateBulkRows(rows: unknown[], now?: Date): BulkValidationResult` + - Iterate **every** row — no early exit. + - For each row `i`: call `taskSchema.safeParse(row)` and extract Zod issues formatted as `"fieldName: Zod message"` by reading `issue.path[0]` and `issue.message`. + - After `safeParse`, when the parsed deadline is valid, compare against `now` (defaulting to `new Date()`) to enforce the 365-day upper bound: if `parsedDeadline.getTime() > now.getTime() + 365 * 24 * 60 * 60 * 1000`, replace any existing deadline message with `"deadline: Deadline cannot be more than 365 days from now"`. + - Check `poster` separately: if `(row as any).poster` is absent or trims to `""`, append `"poster: Poster address is required"`. + - Collect all messages; if any exist push `{ rowIndex: i, message: messages.join("; ") }` to `errors`; otherwise push `{ index: i, input: { ...validatedFields, poster, deadline: parsedDeadlineAsUnixSeconds } }` to `validRows`. + - Return `{ validRows, errors }`. + - _Requirements: 2.1–2.9, 3.1–3.3_ + + - [x] 1.3 Implement `insertValidRows(validRows: Array<{ index: number; input: CreateTaskInput }>, now?: Date): BulkImportResult` + - Iterate every entry in `validRows`. + - Call `createTask(entry.input, now)` for each. + - If `ok: true`, increment `successCount`. + - If `ok: false` (defensive path), increment `errorCount` and push `{ rowIndex: entry.index, message: result.error }` to `errors`. + - Return `BulkImportResult` with `totalProcessed: validRows.length`, `successCount`, `errorCount`, `errors`. + - Import `createTask` from `@/lib/task-workflow`. + - _Requirements: 4.1–4.7_ + +- [x] 2. Create route handler (`src/app/api/tasks/bulk/route.ts`) + - [x] 2.1 Scaffold module exports and import dependencies + - `export const runtime = "nodejs"` and `export const dynamic = "force-dynamic"`. + - Import `checkRateLimit` from `@/lib/rate-limit`, `buildNoStoreJson` from `@/lib/api-response`, `validateBulkRows` and `insertValidRows` from `@/lib/bulk-task-validation`. + - _Requirements: 7.1, 7.3_ + + - [x] 2.2 Implement the `POST` handler with ordered structural checks + - **Step 1** — call `checkRateLimit(request)`; if `response` is non-null, return it immediately (HTTP 429). _Requirements: 7.1–7.4_ + - **Step 2** — read `Content-Type` header; if it does not include `"application/json"`, return `buildNoStoreJson({ ok: false, error: "Content-Type must be application/json." }, 415, rateLimitHeaders)`. _Requirements: 1.8_ + - **Step 3** — `await request.json()` inside try/catch; on throw return `buildNoStoreJson({ ok: false, error: "Request body must be valid JSON." }, 400, rateLimitHeaders)`. _Requirements: 1.2_ + - **Step 4** — `Array.isArray(body)` guard; if false return `buildNoStoreJson({ ok: false, error: "Request body must be a JSON array." }, 400, rateLimitHeaders)`. _Requirements: 1.3_ + - **Step 5** — `body.length === 0` guard; return `buildNoStoreJson({ ok: false, error: "Batch must contain at least 1 row." }, 400, rateLimitHeaders)`. _Requirements: 1.4, 6.2_ + - **Step 6** — `body.length > 100` guard; return `buildNoStoreJson({ ok: false, error: "Batch size exceeds the maximum of 100 rows." }, 400, rateLimitHeaders)`. _Requirements: 1.5, 6.1_ + - **Step 7** — scan for first non-null-object element; return `buildNoStoreJson({ ok: false, error: \`Row ${i} is not a valid object.\` }, 400, rateLimitHeaders)` on first hit. _Requirements: 1.1, 1.6_ + - **Step 8** — call `validateBulkRows(body, new Date())`. + - **Step 9** — call `insertValidRows(validRows, new Date())`. + - **Step 10** — merge `validationResult.errors` and `insertionResult.errors`, compute `totalProcessed = body.length`, `successCount = insertionResult.successCount`, `errorCount = mergedErrors.length`, return `buildNoStoreJson({ totalProcessed, successCount, errorCount, errors: mergedErrors }, 200, rateLimitHeaders)`. _Requirements: 3.4, 5.1–5.8_ + - Set `Cache-Control: no-store` via `buildNoStoreJson`. _Requirements: 5.6_ + - Attach rate-limit headers to all non-429 responses. _Requirements: 5.7_ + +- [~] 3. Checkpoint — verify route handler compiles and imports resolve + - Ensure `pnpm build` (or `tsc --noEmit`) passes with no type errors across the two new files before adding tests. + +- [x] 4. Add `fast-check` dev dependency and write tests (`src/lib/bulk-task-validation.test.ts`) + - [x] 4.1 Install `fast-check` and scaffold test file + - Run `pnpm add -D fast-check@3` to add the property-testing library. + - Create `src/lib/bulk-task-validation.test.ts` with top-level imports: `vitest` (`describe`, `it`, `expect`, `beforeEach`), `fast-check` (`fc`), `resetTaskWorkflowStore` from `@/lib/task-workflow`, `validateBulkRows` and `insertValidRows` from `@/lib/bulk-task-validation`, and the exported `POST` handler from `@/app/api/tasks/bulk/route`. + - Define `buildValidRow(overrides?, now?)` helper that returns a minimally valid row object (title ≥ 5 chars, description ≥ 10 chars, 56-char `G…` tokenAddress, reward = 1_000_000, deadline as Unix-second timestamp 1 day in future, maxSubmissions = 1, non-blank poster). + - Define `buildInvalidRow(invalidFields)` helper that starts from a valid row and applies the specified invalid field values. + - _Requirements: 8.1–8.5_ + + - [x] 4.2 Write Suite 1 — 100% valid payload (unit tests) + - `beforeEach`: `resetTaskWorkflowStore()`. + - Test: submit 3 fully valid rows through `validateBulkRows` then `insertValidRows`; assert `successCount === 3`, `errors.length === 0`, and `tasks` store contains 3 entries (verify by calling `createTask` result count or checking store via repeated `getTask` calls). + - _Requirements: 8.1, 4.3_ + + - [x] 4.3 Write Suite 2 — mixed payload (unit tests) + - `beforeEach`: `resetTaskWorkflowStore()`. + - Build 5 rows: indices 0, 2, 4 valid; indices 1, 3 invalid (e.g., blank title and negative reward respectively). + - Assert `successCount === 3`, `errorCount === 2`, `errors[0].rowIndex === 1`, `errors[1].rowIndex === 3`, store contains exactly 3 tasks. + - _Requirements: 8.2, 3.6, 4.4_ + + - [x] 4.4 Write Suite 3 — 100% invalid payload (unit tests) + - `beforeEach`: `resetTaskWorkflowStore()`. + - Build 3 rows each failing at least one field. + - Assert `successCount === 0`, `errors.length === 3`, store is empty (call `createTask` for a sentinel valid row afterward to confirm `nextTaskId` is 1, meaning no prior insertions occurred). + - _Requirements: 8.3, 3.5, 4.5_ + + - [x] 4.5 Write Suite 4 — structural validation (unit tests against POST handler) + - `beforeEach`: `resetTaskWorkflowStore()`. + - Test empty array `[]`: call `POST` with valid headers, assert HTTP 400, body `{ ok: false, error: "Batch must contain at least 1 row." }`. + - Test 101-element array: assert HTTP 400, body `{ ok: false, error: "Batch size exceeds the maximum of 100 rows." }`. + - Test non-object element at index 2 (e.g., `[validRow, validRow, "oops", validRow]`): assert HTTP 400, body `{ ok: false, error: "Row 2 is not a valid object." }`. + - Test invalid JSON body: simulate by building a `Request` with `body: "not-json"` and correct `Content-Type`; assert HTTP 400, `error: "Request body must be valid JSON."`. + - Test wrong Content-Type (`text/plain`): assert HTTP 415, `error: "Content-Type must be application/json."`. + - _Requirements: 1.2–1.8, 6.1–6.2_ + + - [ ]* 4.6 Write property test for Property 1 — Conservation Invariant + - **Property 1: Conservation Invariant — `successCount + errorCount === totalProcessed` and `errors.length === errorCount`** + - **Validates: Requirements 5.8, 8.5** + - Use `fc.array(fc.boolean(), { minLength: 1, maxLength: 100 })` to generate a boolean mask; map each `true` → valid row, `false` → invalid row. Call `validateBulkRows` + `insertValidRows`, merge errors, assert conservation invariant holds for each generated batch. + - Tag: `// Feature: bulk-task-import, Property 1: Conservation Invariant` + + - [ ]* 4.7 Write property test for Property 2 — All-Valid Batch Completeness + - **Property 2: All-Valid Batch Completeness — all-valid batch of N rows produces `successCount === N`, `errors === []`** + - **Validates: Requirements 4.3, 8.1** + - Use `fc.integer({ min: 1, max: 100 })` for N, generate N valid rows, assert `successCount === N` and `errors.length === 0`. + - Tag: `// Feature: bulk-task-import, Property 2: All-Valid Batch Completeness` + + - [ ]* 4.8 Write property test for Property 3 — All-Invalid Batch Isolation + - **Property 3: All-Invalid Batch Isolation — all-invalid batch produces `successCount === 0`, store empty** + - **Validates: Requirements 3.5, 4.5, 8.3** + - Generate N all-invalid rows (blank title), assert `successCount === 0`, `errors.length === N`, store empty. + - Tag: `// Feature: bulk-task-import, Property 3: All-Invalid Batch Isolation` + + - [ ]* 4.9 Write property test for Property 5 — Row Index Fidelity + - **Property 5: Row Index Fidelity — every error entry's `rowIndex` matches the zero-based position of its invalid row** + - **Validates: Requirements 1.7, 3.3, 5.5** + - Use `fc.array(fc.boolean(), { minLength: 1, maxLength: 100 })` to determine valid/invalid positions; after calling the pipeline, assert that for every `false` position `j`, `errors` contains an entry with `rowIndex === j`, and no entry has a `rowIndex` matching a valid row's position. + - Tag: `// Feature: bulk-task-import, Property 5: Row Index Fidelity` + + - [ ]* 4.10 Write property test for Property 8 — Repeated Submission Non-Uniqueness + - **Property 8: Repeated Submission Non-Uniqueness — submitting the same valid batch twice both return `successCount === N`** + - **Validates: Requirements 8.4** + - Generate N valid rows, submit once (assert `successCount === N`), submit same rows again (assert `successCount === N`, `errors === []`), confirming no uniqueness constraint. + - Tag: `// Feature: bulk-task-import, Property 8: Repeated Submission Non-Uniqueness` + + - [ ]* 4.11 Write property test for Property 9 — First Non-Object Index Reporting + - **Property 9: First Non-Object Index Reporting — HTTP 400 identifies the first non-null-object index** + - **Validates: Requirements 1.6** + - Use `fc.integer({ min: 0, max: 9 })` to pick index `i` in a 10-element array; insert a string at that index; assert HTTP 400 with `error: \`Row ${i} is not a valid object.\``. + - Tag: `// Feature: bulk-task-import, Property 9: First Non-Object Index Reporting` + +- [x] 5. Final checkpoint — run full test suite + - Run `pnpm test` (which executes `vitest run`) and ensure all tests pass, including existing tests. + - Ensure all tests pass; ask the user if questions arise. + +--- + +## Notes + +- Tasks marked with `*` are optional and can be skipped for a faster MVP; the unit tests in 4.2–4.5 are the priority. +- `fast-check` is only needed if property tests (4.6–4.11) are included; install it before starting task 4.1 if property tests are desired. +- The `now` parameter on `validateBulkRows` and `insertValidRows` allows test suites to freeze time, ensuring deadline comparisons are deterministic. +- `buildNoStoreJson` already sets `Cache-Control: no-store`, satisfying Requirement 5.6 with no extra code. +- The route handler must pass `new Date()` once per request invocation (not once per row) so all rows in a batch share the same reference time. +- The `taskSchema` deadline refine compares against `Date.now()` at parse-time, which is fine for production but makes tests brittle; the 365-day upper-bound check in `validateBulkRows` is done manually after `safeParse` to allow time injection. + +## Task Dependency Graph + +```json +{ + "waves": [ + { "id": 0, "tasks": ["1.1"] }, + { "id": 1, "tasks": ["1.2"] }, + { "id": 2, "tasks": ["1.3"] }, + { "id": 3, "tasks": ["2.1"] }, + { "id": 4, "tasks": ["2.2"] }, + { "id": 5, "tasks": ["4.1"] }, + { "id": 6, "tasks": ["4.2", "4.3", "4.4", "4.5"] }, + { "id": 7, "tasks": ["4.6", "4.7", "4.8", "4.9", "4.10", "4.11"] } + ] +} +``` diff --git a/frontend/package.json b/frontend/package.json index aeb0aed..89eb276 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -49,6 +49,7 @@ "@types/react-dom": "^19", "eslint": "^9", "eslint-config-next": "16.1.3", + "fast-check": "3", "tailwindcss": "^4", "typescript": "^5", "vitest": "^4.1.10" diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 34d52bc..1f4282a 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -10,7 +10,7 @@ importers: dependencies: '@creit.tech/stellar-wallets-kit': specifier: ^1.9.5 - version: 1.9.5(@solana/sysvars@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3))(@stellar/stellar-base@13.1.0)(@stellar/stellar-sdk@13.3.0)(@trezor/connect@9.6.2(@solana/sysvars@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3))(bufferutil@4.1.0)(fastestsmallesttextencoderdecoder@1.0.22)(tslib@2.8.1)(typescript@5.9.3)(utf-8-validate@5.0.10)(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(@types/react@19.2.10)(bufferutil@4.1.0)(fastestsmallesttextencoderdecoder@1.0.22)(near-api-js@5.1.1)(react@19.2.3)(tslib@2.8.1)(typescript@5.9.3)(utf-8-validate@5.0.10)(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)) + version: 1.9.5(@solana/sysvars@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3))(@stellar/stellar-base@13.1.0)(@stellar/stellar-sdk@13.3.0)(@trezor/connect@9.6.2(@solana/sysvars@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3))(bufferutil@4.1.0)(fastestsmallesttextencoderdecoder@1.0.22)(tslib@2.8.1)(typescript@5.9.3)(utf-8-validate@5.0.10)(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(@types/react@19.2.10)(bufferutil@4.1.0)(fastestsmallesttextencoderdecoder@1.0.22)(near-api-js@5.1.1)(react@19.2.3)(tslib@2.8.1)(typescript@5.9.3)(utf-8-validate@5.0.10)(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10)) '@paunovic/random-words': specifier: ^1.2.1 version: 1.2.1 @@ -117,6 +117,9 @@ importers: eslint-config-next: specifier: 16.1.3 version: 16.1.3(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + fast-check: + specifier: '3' + version: 3.23.2 tailwindcss: specifier: ^4 version: 4.1.18 @@ -3270,6 +3273,10 @@ packages: resolution: {integrity: sha512-GipyPsXO1anza0AOZdy69Im7hGFCNB7Y/NGjDlZGJ3GJJLtwNSb2vrzYrTYJRrRloVx7pl+bhUaTB8yiccPvFQ==} engines: {node: '> 0.1.90'} + fast-check@3.23.2: + resolution: {integrity: sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==} + engines: {node: '>=8.0.0'} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -4324,6 +4331,9 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + pure-rand@6.1.0: + resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} + pushdata-bitcoin@1.0.1: resolution: {integrity: sha512-hw7rcYTJRAl4olM8Owe8x0fBuJJ+WGbMhQuLWOXEMN3PxPCKQHRkhfL+XG0+iXUmSHjkMmb3Ba55Mt21cZc9kQ==} @@ -5411,7 +5421,7 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 - '@creit.tech/stellar-wallets-kit@1.9.5(@solana/sysvars@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3))(@stellar/stellar-base@13.1.0)(@stellar/stellar-sdk@13.3.0)(@trezor/connect@9.6.2(@solana/sysvars@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3))(bufferutil@4.1.0)(fastestsmallesttextencoderdecoder@1.0.22)(tslib@2.8.1)(typescript@5.9.3)(utf-8-validate@5.0.10)(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(@types/react@19.2.10)(bufferutil@4.1.0)(fastestsmallesttextencoderdecoder@1.0.22)(near-api-js@5.1.1)(react@19.2.3)(tslib@2.8.1)(typescript@5.9.3)(utf-8-validate@5.0.10)(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))': + '@creit.tech/stellar-wallets-kit@1.9.5(@solana/sysvars@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3))(@stellar/stellar-base@13.1.0)(@stellar/stellar-sdk@13.3.0)(@trezor/connect@9.6.2(@solana/sysvars@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3))(bufferutil@4.1.0)(fastestsmallesttextencoderdecoder@1.0.22)(tslib@2.8.1)(typescript@5.9.3)(utf-8-validate@5.0.10)(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(@types/react@19.2.10)(bufferutil@4.1.0)(fastestsmallesttextencoderdecoder@1.0.22)(near-api-js@5.1.1)(react@19.2.3)(tslib@2.8.1)(typescript@5.9.3)(utf-8-validate@5.0.10)(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))': dependencies: '@albedo-link/intent': 0.12.0 '@creit.tech/xbull-wallet-connect': 0.4.0 @@ -5426,8 +5436,8 @@ snapshots: '@ngneat/elf-persist-state': 1.2.1(rxjs@7.8.1) '@stellar/freighter-api': 5.0.0 '@stellar/stellar-base': 13.1.0 - '@trezor/connect-plugin-stellar': 9.2.1(@stellar/stellar-sdk@13.3.0)(@trezor/connect@9.6.2(@solana/sysvars@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3))(bufferutil@4.1.0)(fastestsmallesttextencoderdecoder@1.0.22)(tslib@2.8.1)(typescript@5.9.3)(utf-8-validate@5.0.10)(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(tslib@2.8.1) - '@trezor/connect-web': 9.6.2(@solana/sysvars@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3))(bufferutil@4.1.0)(fastestsmallesttextencoderdecoder@1.0.22)(tslib@2.8.1)(typescript@5.9.3)(utf-8-validate@5.0.10)(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)) + '@trezor/connect-plugin-stellar': 9.2.1(@stellar/stellar-sdk@13.3.0)(@trezor/connect@9.6.2(@solana/sysvars@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3))(bufferutil@4.1.0)(fastestsmallesttextencoderdecoder@1.0.22)(tslib@2.8.1)(typescript@5.9.3)(utf-8-validate@5.0.10)(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(tslib@2.8.1) + '@trezor/connect-web': 9.6.2(@solana/sysvars@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3))(bufferutil@4.1.0)(fastestsmallesttextencoderdecoder@1.0.22)(tslib@2.8.1)(typescript@5.9.3)(utf-8-validate@5.0.10)(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10)) '@walletconnect/modal': 2.6.2(@types/react@19.2.10)(react@19.2.3) '@walletconnect/sign-client': 2.11.2(bufferutil@4.1.0)(utf-8-validate@5.0.10) buffer: 6.0.3 @@ -6897,26 +6907,26 @@ snapshots: '@sinclair/typebox@0.33.22': {} - '@solana-program/compute-budget@0.8.0(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))': + '@solana-program/compute-budget@0.8.0(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10)))': dependencies: - '@solana/kit': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)) + '@solana/kit': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10)) - '@solana-program/stake@0.2.1(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))': + '@solana-program/stake@0.2.1(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10)))': dependencies: - '@solana/kit': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)) + '@solana/kit': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10)) - '@solana-program/system@0.7.0(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))': + '@solana-program/system@0.7.0(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10)))': dependencies: - '@solana/kit': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)) + '@solana/kit': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10)) - '@solana-program/token-2022@0.4.2(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(@solana/sysvars@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3))': + '@solana-program/token-2022@0.4.2(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(@solana/sysvars@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3))': dependencies: - '@solana/kit': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)) + '@solana/kit': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10)) '@solana/sysvars': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) - '@solana-program/token@0.5.1(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))': + '@solana-program/token@0.5.1(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10)))': dependencies: - '@solana/kit': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)) + '@solana/kit': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10)) '@solana/accounts@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)': dependencies: @@ -7018,7 +7028,7 @@ snapshots: transitivePeerDependencies: - fastestsmallesttextencoderdecoder - '@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))': + '@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))': dependencies: '@solana/accounts': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) '@solana/addresses': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) @@ -7031,11 +7041,11 @@ snapshots: '@solana/rpc': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) '@solana/rpc-parsed-types': 2.3.0(typescript@5.9.3) '@solana/rpc-spec-types': 2.3.0(typescript@5.9.3) - '@solana/rpc-subscriptions': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)) + '@solana/rpc-subscriptions': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10)) '@solana/rpc-types': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) '@solana/signers': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) '@solana/sysvars': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) - '@solana/transaction-confirmation': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)) + '@solana/transaction-confirmation': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10)) '@solana/transaction-messages': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) '@solana/transactions': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) typescript: 5.9.3 @@ -7114,14 +7124,14 @@ snapshots: transitivePeerDependencies: - fastestsmallesttextencoderdecoder - '@solana/rpc-subscriptions-channel-websocket@2.3.0(typescript@5.9.3)(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))': + '@solana/rpc-subscriptions-channel-websocket@2.3.0(typescript@5.9.3)(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))': dependencies: '@solana/errors': 2.3.0(typescript@5.9.3) '@solana/functional': 2.3.0(typescript@5.9.3) '@solana/rpc-subscriptions-spec': 2.3.0(typescript@5.9.3) '@solana/subscribable': 2.3.0(typescript@5.9.3) typescript: 5.9.3 - ws: 8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10) + ws: 7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10) '@solana/rpc-subscriptions-spec@2.3.0(typescript@5.9.3)': dependencies: @@ -7131,7 +7141,7 @@ snapshots: '@solana/subscribable': 2.3.0(typescript@5.9.3) typescript: 5.9.3 - '@solana/rpc-subscriptions@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))': + '@solana/rpc-subscriptions@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))': dependencies: '@solana/errors': 2.3.0(typescript@5.9.3) '@solana/fast-stable-stringify': 2.3.0(typescript@5.9.3) @@ -7139,7 +7149,7 @@ snapshots: '@solana/promises': 2.3.0(typescript@5.9.3) '@solana/rpc-spec-types': 2.3.0(typescript@5.9.3) '@solana/rpc-subscriptions-api': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) - '@solana/rpc-subscriptions-channel-websocket': 2.3.0(typescript@5.9.3)(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)) + '@solana/rpc-subscriptions-channel-websocket': 2.3.0(typescript@5.9.3)(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10)) '@solana/rpc-subscriptions-spec': 2.3.0(typescript@5.9.3) '@solana/rpc-transformers': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) '@solana/rpc-types': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) @@ -7224,7 +7234,7 @@ snapshots: transitivePeerDependencies: - fastestsmallesttextencoderdecoder - '@solana/transaction-confirmation@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))': + '@solana/transaction-confirmation@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))': dependencies: '@solana/addresses': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) '@solana/codecs-strings': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) @@ -7232,7 +7242,7 @@ snapshots: '@solana/keys': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) '@solana/promises': 2.3.0(typescript@5.9.3) '@solana/rpc': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) - '@solana/rpc-subscriptions': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)) + '@solana/rpc-subscriptions': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10)) '@solana/rpc-types': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) '@solana/transaction-messages': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) '@solana/transactions': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) @@ -7540,12 +7550,12 @@ snapshots: - react-native - utf-8-validate - '@trezor/blockchain-link@2.5.2(@solana/sysvars@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3))(bufferutil@4.1.0)(fastestsmallesttextencoderdecoder@1.0.22)(tslib@2.8.1)(typescript@5.9.3)(utf-8-validate@5.0.10)(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))': + '@trezor/blockchain-link@2.5.2(@solana/sysvars@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3))(bufferutil@4.1.0)(fastestsmallesttextencoderdecoder@1.0.22)(tslib@2.8.1)(typescript@5.9.3)(utf-8-validate@5.0.10)(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))': dependencies: - '@solana-program/stake': 0.2.1(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))) - '@solana-program/token': 0.5.1(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))) - '@solana-program/token-2022': 0.4.2(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(@solana/sysvars@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)) - '@solana/kit': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)) + '@solana-program/stake': 0.2.1(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))) + '@solana-program/token': 0.5.1(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))) + '@solana-program/token-2022': 0.4.2(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(@solana/sysvars@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)) + '@solana/kit': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10)) '@solana/rpc-types': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) '@stellar/stellar-sdk': 13.3.0 '@trezor/blockchain-link-types': 1.4.2(tslib@2.8.1) @@ -7592,16 +7602,16 @@ snapshots: - expo-localization - react-native - '@trezor/connect-plugin-stellar@9.2.1(@stellar/stellar-sdk@13.3.0)(@trezor/connect@9.6.2(@solana/sysvars@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3))(bufferutil@4.1.0)(fastestsmallesttextencoderdecoder@1.0.22)(tslib@2.8.1)(typescript@5.9.3)(utf-8-validate@5.0.10)(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(tslib@2.8.1)': + '@trezor/connect-plugin-stellar@9.2.1(@stellar/stellar-sdk@13.3.0)(@trezor/connect@9.6.2(@solana/sysvars@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3))(bufferutil@4.1.0)(fastestsmallesttextencoderdecoder@1.0.22)(tslib@2.8.1)(typescript@5.9.3)(utf-8-validate@5.0.10)(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(tslib@2.8.1)': dependencies: '@stellar/stellar-sdk': 13.3.0 - '@trezor/connect': 9.6.2(@solana/sysvars@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3))(bufferutil@4.1.0)(fastestsmallesttextencoderdecoder@1.0.22)(tslib@2.8.1)(typescript@5.9.3)(utf-8-validate@5.0.10)(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)) + '@trezor/connect': 9.6.2(@solana/sysvars@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3))(bufferutil@4.1.0)(fastestsmallesttextencoderdecoder@1.0.22)(tslib@2.8.1)(typescript@5.9.3)(utf-8-validate@5.0.10)(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10)) '@trezor/utils': 9.4.1(tslib@2.8.1) tslib: 2.8.1 - '@trezor/connect-web@9.6.2(@solana/sysvars@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3))(bufferutil@4.1.0)(fastestsmallesttextencoderdecoder@1.0.22)(tslib@2.8.1)(typescript@5.9.3)(utf-8-validate@5.0.10)(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))': + '@trezor/connect-web@9.6.2(@solana/sysvars@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3))(bufferutil@4.1.0)(fastestsmallesttextencoderdecoder@1.0.22)(tslib@2.8.1)(typescript@5.9.3)(utf-8-validate@5.0.10)(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))': dependencies: - '@trezor/connect': 9.6.2(@solana/sysvars@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3))(bufferutil@4.1.0)(fastestsmallesttextencoderdecoder@1.0.22)(tslib@2.8.1)(typescript@5.9.3)(utf-8-validate@5.0.10)(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)) + '@trezor/connect': 9.6.2(@solana/sysvars@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3))(bufferutil@4.1.0)(fastestsmallesttextencoderdecoder@1.0.22)(tslib@2.8.1)(typescript@5.9.3)(utf-8-validate@5.0.10)(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10)) '@trezor/connect-common': 0.4.2(tslib@2.8.1) '@trezor/utils': 9.4.2(tslib@2.8.1) '@trezor/websocket-client': 1.2.2(bufferutil@4.1.0)(tslib@2.8.1)(utf-8-validate@5.0.10) @@ -7621,7 +7631,7 @@ snapshots: - utf-8-validate - ws - '@trezor/connect@9.6.2(@solana/sysvars@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3))(bufferutil@4.1.0)(fastestsmallesttextencoderdecoder@1.0.22)(tslib@2.8.1)(typescript@5.9.3)(utf-8-validate@5.0.10)(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))': + '@trezor/connect@9.6.2(@solana/sysvars@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3))(bufferutil@4.1.0)(fastestsmallesttextencoderdecoder@1.0.22)(tslib@2.8.1)(typescript@5.9.3)(utf-8-validate@5.0.10)(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))': dependencies: '@ethereumjs/common': 10.1.0 '@ethereumjs/tx': 10.1.0 @@ -7629,12 +7639,12 @@ snapshots: '@mobily/ts-belt': 3.13.1 '@noble/hashes': 1.8.0 '@scure/bip39': 1.6.0 - '@solana-program/compute-budget': 0.8.0(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))) - '@solana-program/system': 0.7.0(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))) - '@solana-program/token': 0.5.1(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))) - '@solana-program/token-2022': 0.4.2(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(@solana/sysvars@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)) - '@solana/kit': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)) - '@trezor/blockchain-link': 2.5.2(@solana/sysvars@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3))(bufferutil@4.1.0)(fastestsmallesttextencoderdecoder@1.0.22)(tslib@2.8.1)(typescript@5.9.3)(utf-8-validate@5.0.10)(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)) + '@solana-program/compute-budget': 0.8.0(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))) + '@solana-program/system': 0.7.0(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))) + '@solana-program/token': 0.5.1(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10))) + '@solana-program/token-2022': 0.4.2(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(@solana/sysvars@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)) + '@solana/kit': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10)) + '@trezor/blockchain-link': 2.5.2(@solana/sysvars@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3))(bufferutil@4.1.0)(fastestsmallesttextencoderdecoder@1.0.22)(tslib@2.8.1)(typescript@5.9.3)(utf-8-validate@5.0.10)(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10)) '@trezor/blockchain-link-types': 1.4.2(tslib@2.8.1) '@trezor/blockchain-link-utils': 1.4.2(bufferutil@4.1.0)(tslib@2.8.1)(utf-8-validate@5.0.10) '@trezor/connect-analytics': 1.3.5(tslib@2.8.1) @@ -9246,6 +9256,10 @@ snapshots: eyes@0.1.8: {} + fast-check@3.23.2: + dependencies: + pure-rand: 6.1.0 + fast-deep-equal@3.1.3: {} fast-glob@3.3.1: @@ -10292,6 +10306,8 @@ snapshots: punycode@2.3.1: {} + pure-rand@6.1.0: {} + pushdata-bitcoin@1.0.1: dependencies: bitcoin-ops: 1.4.1 diff --git a/frontend/src/app/api/tasks/bulk/route.ts b/frontend/src/app/api/tasks/bulk/route.ts new file mode 100644 index 0000000..5d973c0 --- /dev/null +++ b/frontend/src/app/api/tasks/bulk/route.ts @@ -0,0 +1,97 @@ +import { checkRateLimit } from "@/lib/rate-limit"; +import { buildNoStoreJson } from "@/lib/api-response"; +import { validateBulkRows, insertValidRows } from "@/lib/bulk-task-validation"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function POST(request: Request) { + // Step 1: Rate limit check + const { response: rateLimitResponse, headers: rateLimitHeaders } = + checkRateLimit(request); + if (rateLimitResponse) { + return rateLimitResponse; + } + + // Step 2: Content-Type check + const contentType = request.headers.get("content-type") ?? ""; + if (!contentType.includes("application/json")) { + return buildNoStoreJson( + { ok: false, error: "Content-Type must be application/json." }, + 415, + rateLimitHeaders, + ); + } + + // Step 3: JSON parse + let body: unknown; + try { + body = await request.json(); + } catch { + return buildNoStoreJson( + { ok: false, error: "Request body must be valid JSON." }, + 400, + rateLimitHeaders, + ); + } + + // Step 4: Array check + if (!Array.isArray(body)) { + return buildNoStoreJson( + { ok: false, error: "Request body must be a JSON array." }, + 400, + rateLimitHeaders, + ); + } + + const rows = body as unknown[]; + + // Step 5: Empty check + if (rows.length === 0) { + return buildNoStoreJson( + { ok: false, error: "Batch must contain at least 1 row." }, + 400, + rateLimitHeaders, + ); + } + + // Step 6: Size limit check + if (rows.length > 100) { + return buildNoStoreJson( + { ok: false, error: "Batch size exceeds the maximum of 100 rows." }, + 400, + rateLimitHeaders, + ); + } + + // Step 7: Non-object element scan + for (let i = 0; i < rows.length; i++) { + const el = rows[i]; + if (el === null || typeof el !== "object" || Array.isArray(el)) { + return buildNoStoreJson( + { ok: false, error: `Row ${i} is not a valid object.` }, + 400, + rateLimitHeaders, + ); + } + } + + // Step 8: Validate rows (capture now once) + const now = new Date(); + const validationResult = validateBulkRows(rows, now); + + // Step 9: Insert valid rows + const insertionResult = insertValidRows(validationResult.validRows, now); + + // Step 10: Build response + const allErrors = [...validationResult.errors, ...insertionResult.errors]; + const totalProcessed = rows.length; + const successCount = insertionResult.successCount; + const errorCount = allErrors.length; + + return buildNoStoreJson( + { totalProcessed, successCount, errorCount, errors: allErrors }, + 200, + rateLimitHeaders, + ); +} diff --git a/frontend/src/lib/bulk-task-validation.test.ts b/frontend/src/lib/bulk-task-validation.test.ts new file mode 100644 index 0000000..4227e05 --- /dev/null +++ b/frontend/src/lib/bulk-task-validation.test.ts @@ -0,0 +1,230 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { resetTaskWorkflowStore } from "@/lib/task-workflow"; +import { POST } from "@/app/api/tasks/bulk/route"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * Build a Web Request suitable for the POST handler. + */ +function makeRequest(body: unknown, contentType = "application/json"): Request { + return new Request("http://localhost/api/tasks/bulk", { + method: "POST", + headers: { "Content-Type": contentType }, + body: JSON.stringify(body), + }); +} + +/** + * Build a Web Request with a raw string body (useful for testing invalid JSON). + */ +function makeRawRequest(rawBody: string, contentType = "application/json"): Request { + return new Request("http://localhost/api/tasks/bulk", { + method: "POST", + headers: { "Content-Type": contentType }, + body: rawBody, + }); +} + +/** + * Produces a minimally valid row object. + * + * - poster: any non-empty string (poster is not validated as a Stellar address in bulk) + * - title: ≥ 5 chars + * - description: ≥ 10 chars + * - tokenAddress: exactly 56 chars, starts with G, uses only A-Z and 2-7 + * - reward: 1_000_000 (= MIN_TASK_REWARD in stroops) + * - deadline: ISO string 1 day in the future + * - maxSubmissions: 1 + */ +function buildValidRow( + now: Date, + overrides: Record = {}, +): Record { + const deadline = new Date(now.getTime() + 24 * 60 * 60 * 1000); // 1 day from now + return { + poster: "poster-wallet-address", + title: "Valid Task Title", + description: "This is a valid task description that meets minimum length.", + // 56 chars: G(1) + A-Z(26) + 2-7(6) + A-W(23) = 56 + tokenAddress: "GABCDEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEFGHIJKLMNOPQRSTUVW", + reward: 1_000_000, + deadline: deadline.toISOString(), + maxSubmissions: 1, + ...overrides, + }; +} + +// --------------------------------------------------------------------------- +// Suite 1 — 100% Valid Payload +// --------------------------------------------------------------------------- + +describe("Suite 1 — 100% valid payload", () => { + beforeEach(() => resetTaskWorkflowStore()); + + it("inserts all rows and returns successCount equal to total", async () => { + const now = new Date(); + const rows = [buildValidRow(now), buildValidRow(now), buildValidRow(now)]; + const response = await POST(makeRequest(rows)); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.totalProcessed).toBe(3); + expect(data.successCount).toBe(3); + expect(data.errorCount).toBe(0); + expect(data.errors).toEqual([]); + }); +}); + +// --------------------------------------------------------------------------- +// Suite 2 — Mixed Payload +// --------------------------------------------------------------------------- + +describe("Suite 2 — mixed payload", () => { + beforeEach(() => resetTaskWorkflowStore()); + + it("inserts only valid rows and reports invalid rows in errors with correct rowIndex", async () => { + const now = new Date(); + const rows = [ + buildValidRow(now), // index 0 — valid + buildValidRow(now, { title: "" }), // index 1 — invalid (blank title) + buildValidRow(now), // index 2 — valid + buildValidRow(now, { reward: 0 }), // index 3 — invalid (reward = 0) + buildValidRow(now), // index 4 — valid + ]; + + const response = await POST(makeRequest(rows)); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.totalProcessed).toBe(5); + expect(data.successCount).toBe(3); + expect(data.errorCount).toBe(2); + expect(data.errors).toHaveLength(2); + expect(data.errors[0].rowIndex).toBe(1); + expect(data.errors[1].rowIndex).toBe(3); + // Verify error messages are non-empty + expect(data.errors[0].message).toBeTruthy(); + expect(data.errors[1].message).toBeTruthy(); + }); +}); + +// --------------------------------------------------------------------------- +// Suite 3 — 100% Invalid Payload +// --------------------------------------------------------------------------- + +describe("Suite 3 — 100% invalid payload", () => { + beforeEach(() => resetTaskWorkflowStore()); + + it("inserts nothing and returns all rows as errors", async () => { + const now = new Date(); + const rows = [ + buildValidRow(now, { title: "" }), // invalid: blank title + buildValidRow(now, { description: "" }), // invalid: blank description + buildValidRow(now, { reward: -1 }), // invalid: negative reward + ]; + + const response = await POST(makeRequest(rows)); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.totalProcessed).toBe(3); + expect(data.successCount).toBe(0); + expect(data.errorCount).toBe(3); + expect(data.errors).toHaveLength(3); + expect(data.errors[0].rowIndex).toBe(0); + expect(data.errors[1].rowIndex).toBe(1); + expect(data.errors[2].rowIndex).toBe(2); + }); +}); + +// --------------------------------------------------------------------------- +// Suite 4 — Structural Validation +// --------------------------------------------------------------------------- + +describe("Suite 4 — structural validation", () => { + beforeEach(() => resetTaskWorkflowStore()); + + it("returns 400 for empty array", async () => { + const response = await POST(makeRequest([])); + const data = await response.json(); + expect(response.status).toBe(400); + expect(data.ok).toBe(false); + expect(data.error).toBe("Batch must contain at least 1 row."); + }); + + it("returns 400 for array with more than 100 elements", async () => { + const now = new Date(); + const rows = Array.from({ length: 101 }, () => buildValidRow(now)); + const response = await POST(makeRequest(rows)); + const data = await response.json(); + expect(response.status).toBe(400); + expect(data.ok).toBe(false); + expect(data.error).toBe("Batch size exceeds the maximum of 100 rows."); + }); + + it("returns 400 when element at index 2 is not an object", async () => { + const now = new Date(); + const rows = [buildValidRow(now), buildValidRow(now), "oops", buildValidRow(now)]; + const response = await POST(makeRequest(rows)); + const data = await response.json(); + expect(response.status).toBe(400); + expect(data.ok).toBe(false); + expect(data.error).toBe("Row 2 is not a valid object."); + }); + + it("returns 400 for invalid JSON body", async () => { + const response = await POST(makeRawRequest("not valid json")); + const data = await response.json(); + expect(response.status).toBe(400); + expect(data.ok).toBe(false); + expect(data.error).toBe("Request body must be valid JSON."); + }); + + it("returns 415 for wrong Content-Type", async () => { + const now = new Date(); + const rows = [buildValidRow(now)]; + const response = await POST(makeRequest(rows, "text/plain")); + const data = await response.json(); + expect(response.status).toBe(415); + expect(data.ok).toBe(false); + expect(data.error).toBe("Content-Type must be application/json."); + }); +}); + +// --------------------------------------------------------------------------- +// Suite 5 — Conservation Invariant +// --------------------------------------------------------------------------- + +describe("Suite 5 — conservation invariant", () => { + beforeEach(() => resetTaskWorkflowStore()); + + it("successCount + errorCount === totalProcessed for all-valid batch", async () => { + const now = new Date(); + const rows = [buildValidRow(now), buildValidRow(now)]; + const response = await POST(makeRequest(rows)); + const data = await response.json(); + expect(data.successCount + data.errorCount).toBe(data.totalProcessed); + expect(data.errors.length).toBe(data.errorCount); + }); + + it("successCount + errorCount === totalProcessed for mixed batch", async () => { + const now = new Date(); + const rows = [buildValidRow(now), buildValidRow(now, { title: "" }), buildValidRow(now)]; + const response = await POST(makeRequest(rows)); + const data = await response.json(); + expect(data.successCount + data.errorCount).toBe(data.totalProcessed); + expect(data.errors.length).toBe(data.errorCount); + }); + + it("successCount + errorCount === totalProcessed for all-invalid batch", async () => { + const now = new Date(); + const rows = [buildValidRow(now, { title: "" }), buildValidRow(now, { reward: 0 })]; + const response = await POST(makeRequest(rows)); + const data = await response.json(); + expect(data.successCount + data.errorCount).toBe(data.totalProcessed); + expect(data.errors.length).toBe(data.errorCount); + }); +}); diff --git a/frontend/src/lib/bulk-task-validation.ts b/frontend/src/lib/bulk-task-validation.ts new file mode 100644 index 0000000..0b7f26b --- /dev/null +++ b/frontend/src/lib/bulk-task-validation.ts @@ -0,0 +1,127 @@ +import type { CreateTaskInput } from "@/types/task-workflow"; +import { taskSchema } from "@/lib/taskValidation"; +import { createTask, MAX_TASK_DEADLINE_OFFSET_SECONDS } from "@/lib/task-workflow"; + +export interface BulkRowError { + rowIndex: number; + message: string; +} + +export interface BulkValidationResult { + validRows: Array<{ index: number; input: CreateTaskInput }>; + errors: BulkRowError[]; +} + +export interface BulkImportResult { + totalProcessed: number; + successCount: number; + errorCount: number; + errors: BulkRowError[]; +} + +export function validateBulkRows(rows: unknown[], now: Date = new Date()): BulkValidationResult { + const validRows: Array<{ index: number; input: CreateTaskInput }> = []; + const errors: BulkRowError[] = []; + + for (let i = 0; i < rows.length; i++) { + const row = rows[i]; + const messages: string[] = []; + + // Step 1: Run taskSchema.safeParse to validate title, description, tokenAddress, + // reward, deadline, maxSubmissions + const parseResult = taskSchema.safeParse(row); + + if (!parseResult.success) { + // Extract Zod error messages formatted as "fieldName: Zod message" + for (const issue of parseResult.error.issues) { + const fieldName = String(issue.path[0]); + messages.push(`${fieldName}: ${issue.message}`); + } + } + + // Step 2: After safeParse, check the 365-day upper bound manually + // This applies whether safeParse succeeded or failed (if deadline parsed successfully) + if (parseResult.success) { + const parsedDate = parseResult.data.deadline; + if (parsedDate.getTime() > now.getTime() + MAX_TASK_DEADLINE_OFFSET_SECONDS * 1000) { + // Replace any existing "Deadline must be in the future" error with the 365-day error + // (365-day takes priority per requirement 2.6) + const futureErrIdx = messages.findIndex((m) => m === "deadline: Deadline must be in the future"); + if (futureErrIdx !== -1) { + messages.splice(futureErrIdx, 1); + } + messages.push("deadline: Deadline cannot be more than 365 days from now"); + } + } else { + // safeParse failed — check if the deadline field parsed as a date but is > 365 days + // We do a partial check: try to coerce the deadline value independently + const rowRecord = row as Record; + const rawDeadline = rowRecord.deadline; + if (rawDeadline !== undefined && rawDeadline !== null) { + const coerced = new Date(rawDeadline as string | number); + if (!isNaN(coerced.getTime()) && coerced.getTime() > now.getTime() + MAX_TASK_DEADLINE_OFFSET_SECONDS * 1000) { + // The deadline is parseable and > 365 days from now. + // Remove "Deadline must be in the future" if present (365-day takes priority) + const futureErrIdx = messages.findIndex((m) => m === "deadline: Deadline must be in the future"); + if (futureErrIdx !== -1) { + messages.splice(futureErrIdx, 1); + messages.push("deadline: Deadline cannot be more than 365 days from now"); + } + } + } + } + + // Step 3: Check poster separately + const rowRecord = row as Record; + const posterValue = rowRecord.poster; + if (posterValue === undefined || posterValue === null || String(posterValue).trim() === "") { + messages.push("poster: Poster address is required"); + } + + // Step 4: Collect results + if (messages.length > 0) { + errors.push({ rowIndex: i, message: messages.join("; ") }); + } else { + // parseResult.success must be true here since messages is empty + // (safeParse only adds to messages on failure, and poster is valid) + const data = (parseResult as Extract).data; + const input: CreateTaskInput = { + poster: String(posterValue).trim(), + title: data.title, + description: data.description, + reward: data.reward, + deadline: Math.floor(data.deadline.getTime() / 1000), + maxSubmissions: data.maxSubmissions, + }; + validRows.push({ index: i, input }); + } + } + + return { validRows, errors }; +} + +export function insertValidRows( + validRows: Array<{ index: number; input: CreateTaskInput }>, + now?: Date, +): BulkImportResult { + let successCount = 0; + let errorCount = 0; + const errors: BulkRowError[] = []; + + for (const entry of validRows) { + const result = createTask(entry.input, now ?? new Date()); + if (result.ok) { + successCount++; + } else { + errorCount++; + errors.push({ rowIndex: entry.index, message: result.error }); + } + } + + return { + totalProcessed: validRows.length, + successCount, + errorCount, + errors, + }; +}