From 500d9111a940f96dbbfbb4abfeeefd5db04c90fe Mon Sep 17 00:00:00 2001 From: coderolisa Date: Sat, 29 Aug 2026 21:26:52 +0100 Subject: [PATCH] feat: add organization model and task relationship Implements the foundational data model for organization profiles: - Add Organization interface with id, name, description, website fields - Add organization-store module for CRUD operations - Change TaskRecord.organization (string) to organizationId (reference) - Add organizationId filter to task queries - Add comprehensive tests (16 tests) for organization persistence and task linkage This establishes the 10% foundation for organization support without implementing UI layer per contribution scope. Co-authored-by: Kiro AI --- frontend/src/app/api/tasks/route.ts | 4 +- frontend/src/components/BountyList.tsx | 2 +- frontend/src/lib/organization-store.test.ts | 403 ++++++++++++++++++++ frontend/src/lib/organization-store.ts | 156 ++++++++ frontend/src/lib/task-listing.test.ts | 18 +- frontend/src/lib/task-workflow.ts | 6 +- frontend/src/types/organization.ts | 19 + frontend/src/types/task-workflow.ts | 10 +- 8 files changed, 598 insertions(+), 20 deletions(-) create mode 100644 frontend/src/lib/organization-store.test.ts create mode 100644 frontend/src/lib/organization-store.ts create mode 100644 frontend/src/types/organization.ts diff --git a/frontend/src/app/api/tasks/route.ts b/frontend/src/app/api/tasks/route.ts index 5746c85..889467d 100644 --- a/frontend/src/app/api/tasks/route.ts +++ b/frontend/src/app/api/tasks/route.ts @@ -35,7 +35,7 @@ export async function GET(request: Request) { ? (difficultyParam as TaskDifficulty) : undefined, technology: searchParams.get("technology") ?? undefined, - organization: searchParams.get("organization") ?? undefined, + organizationId: searchParams.get("organizationId") ?? undefined, sort: VALID_SORTS.includes(sortParam as TaskSortOrder) ? (sortParam as TaskSortOrder) : undefined, @@ -109,7 +109,7 @@ export async function POST(request: Request) { technologies: Array.isArray(payload.technologies) ? payload.technologies.map((tech) => String(tech)) : undefined, - organization: payload.organization ? String(payload.organization) : undefined, + organizationId: payload.organizationId ? String(payload.organizationId) : undefined, }); if (!result.ok) { diff --git a/frontend/src/components/BountyList.tsx b/frontend/src/components/BountyList.tsx index 8f5c8af..622664c 100644 --- a/frontend/src/components/BountyList.tsx +++ b/frontend/src/components/BountyList.tsx @@ -63,7 +63,7 @@ export function BountyList({ tasks, pagination, isLoading, error, onPageChange }
{task.title} - {task.organization ? `${task.organization} • ` : ""} + {task.organizationId ? `Org ${task.organizationId} • ` : ""} Deadline {formatDeadline(task.deadline)}
diff --git a/frontend/src/lib/organization-store.test.ts b/frontend/src/lib/organization-store.test.ts new file mode 100644 index 0000000..1a6f36d --- /dev/null +++ b/frontend/src/lib/organization-store.test.ts @@ -0,0 +1,403 @@ +/** + * Organization Store Tests + * + * Tests organization persistence and task linkage. + */ + +import { describe, it, expect, beforeEach } from "vitest"; +import { + createOrganization, + getOrganization, + getOrganizationByName, + listOrganizations, + resetOrganizationStore, +} from "./organization-store"; +import { + createTask, + listTasks, + resetTaskWorkflowStore, +} from "./task-workflow"; + +describe("Organization Store", () => { + beforeEach(() => { + resetOrganizationStore(); + resetTaskWorkflowStore(); + }); + + describe("Organization Persistence", () => { + it("creates an organization with required fields", () => { + const result = createOrganization({ + name: "Stellar Development Foundation", + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + expect(result.organization.id).toBeDefined(); + expect(result.organization.name).toBe("Stellar Development Foundation"); + expect(result.organization.description).toBe(""); + expect(result.organization.website).toBe(""); + expect(result.organization.createdAt).toBeDefined(); + }); + + it("creates an organization with all fields", () => { + const result = createOrganization({ + name: "Soroswap Labs", + description: "Building the premier DEX on Stellar", + website: "https://soroswap.finance", + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + expect(result.organization.name).toBe("Soroswap Labs"); + expect(result.organization.description).toBe("Building the premier DEX on Stellar"); + expect(result.organization.website).toBe("https://soroswap.finance"); + }); + + it("retrieves an organization by ID", () => { + const createResult = createOrganization({ + name: "Blend Protocol", + description: "Lending on Stellar", + }); + + expect(createResult.ok).toBe(true); + if (!createResult.ok) return; + + const getResult = getOrganization(createResult.organization.id); + + expect(getResult.ok).toBe(true); + if (!getResult.ok) return; + + expect(getResult.organization.id).toBe(createResult.organization.id); + expect(getResult.organization.name).toBe("Blend Protocol"); + }); + + it("retrieves an organization by name (case-insensitive)", () => { + createOrganization({ + name: "Stellar Foundation", + }); + + const result1 = getOrganizationByName("Stellar Foundation"); + expect(result1.ok).toBe(true); + + const result2 = getOrganizationByName("stellar foundation"); + expect(result2.ok).toBe(true); + + const result3 = getOrganizationByName("STELLAR FOUNDATION"); + expect(result3.ok).toBe(true); + }); + + it("returns error for non-existent organization", () => { + const result = getOrganization("nonexistent"); + + expect(result.ok).toBe(false); + if (result.ok) return; + + expect(result.status).toBe(404); + expect(result.error).toBe("Organization not found."); + }); + + it("enforces unique organization names (case-insensitive)", () => { + const result1 = createOrganization({ name: "Unique Org" }); + expect(result1.ok).toBe(true); + + const result2 = createOrganization({ name: "Unique Org" }); + expect(result2.ok).toBe(false); + if (result2.ok) return; + + expect(result2.status).toBe(409); + expect(result2.error).toBe("An organization with this name already exists."); + + // Case-insensitive duplicate + const result3 = createOrganization({ name: "unique org" }); + expect(result3.ok).toBe(false); + }); + + it("validates required organization name", () => { + const result = createOrganization({ name: "" }); + + expect(result.ok).toBe(false); + if (result.ok) return; + + expect(result.status).toBe(400); + expect(result.details).toContain("Organization name is required."); + }); + + it("validates organization name length", () => { + const result1 = createOrganization({ name: "A" }); + expect(result1.ok).toBe(false); + if (result1.ok) return; + expect(result1.details).toContain("Organization name must be at least 2 characters."); + + const result2 = createOrganization({ name: "A".repeat(101) }); + expect(result2.ok).toBe(false); + if (result2.ok) return; + expect(result2.details).toContain("Organization name must not exceed 100 characters."); + }); + + it("validates website URL format", () => { + const result1 = createOrganization({ + name: "Test Org", + website: "not-a-url", + }); + + expect(result1.ok).toBe(false); + if (result1.ok) return; + expect(result1.details).toContain("Website must be a valid URL (http:// or https://)."); + + const result2 = createOrganization({ + name: "Test Org 2", + website: "https://example.com", + }); + expect(result2.ok).toBe(true); + }); + + it("validates description length", () => { + const result = createOrganization({ + name: "Test Org", + description: "A".repeat(501), + }); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.details).toContain("Description must not exceed 500 characters."); + }); + + it("lists all organizations", () => { + createOrganization({ name: "Org 1" }); + createOrganization({ name: "Org 2" }); + createOrganization({ name: "Org 3" }); + + const orgs = listOrganizations(); + + expect(orgs).toHaveLength(3); + expect(orgs.map((o) => o.name)).toEqual( + expect.arrayContaining(["Org 1", "Org 2", "Org 3"]) + ); + }); + + it("lists empty array when no organizations exist", () => { + const orgs = listOrganizations(); + expect(orgs).toHaveLength(0); + }); + }); + + describe("Task-Organization Linkage", () => { + it("creates a task linked to an organization", () => { + const orgResult = createOrganization({ + name: "TaskBounty DAO", + description: "Decentralized task marketplace", + }); + + expect(orgResult.ok).toBe(true); + if (!orgResult.ok) return; + + const taskResult = createTask({ + poster: "GABC123", + title: "Build a frontend component", + description: "Create a React component for task listing", + reward: 10_000_000, + deadline: Math.floor(Date.now() / 1000) + 86400, + maxSubmissions: 3, + organizationId: orgResult.organization.id, + }); + + expect(taskResult.ok).toBe(true); + if (!taskResult.ok) return; + + expect(taskResult.task.organizationId).toBe(orgResult.organization.id); + }); + + it("creates a task without an organization", () => { + const taskResult = createTask({ + poster: "GXYZ789", + title: "Independent task", + description: "Task without organization", + reward: 5_000_000, + deadline: Math.floor(Date.now() / 1000) + 86400, + maxSubmissions: 1, + }); + + expect(taskResult.ok).toBe(true); + if (!taskResult.ok) return; + + expect(taskResult.task.organizationId).toBe(""); + }); + + it("filters tasks by organization ID", () => { + const org1Result = createOrganization({ name: "Org Alpha" }); + const org2Result = createOrganization({ name: "Org Beta" }); + + expect(org1Result.ok && org2Result.ok).toBe(true); + if (!org1Result.ok || !org2Result.ok) return; + + createTask({ + poster: "GTEST1", + title: "Task 1", + description: "First task", + reward: 1_000_000, + deadline: Math.floor(Date.now() / 1000) + 86400, + maxSubmissions: 1, + organizationId: org1Result.organization.id, + }); + + createTask({ + poster: "GTEST2", + title: "Task 2", + description: "Second task", + reward: 2_000_000, + deadline: Math.floor(Date.now() / 1000) + 86400, + maxSubmissions: 1, + organizationId: org1Result.organization.id, + }); + + createTask({ + poster: "GTEST3", + title: "Task 3", + description: "Third task", + reward: 3_000_000, + deadline: Math.floor(Date.now() / 1000) + 86400, + maxSubmissions: 1, + organizationId: org2Result.organization.id, + }); + + const org1Tasks = listTasks({ organizationId: org1Result.organization.id }); + expect(org1Tasks.tasks).toHaveLength(2); + expect(org1Tasks.tasks.map((t) => t.title)).toEqual( + expect.arrayContaining(["Task 1", "Task 2"]) + ); + + const org2Tasks = listTasks({ organizationId: org2Result.organization.id }); + expect(org2Tasks.tasks).toHaveLength(1); + expect(org2Tasks.tasks[0].title).toBe("Task 3"); + }); + + it("allows multiple tasks per organization", () => { + const orgResult = createOrganization({ name: "Prolific Org" }); + expect(orgResult.ok).toBe(true); + if (!orgResult.ok) return; + + const taskCount = 5; + for (let i = 1; i <= taskCount; i++) { + const result = createTask({ + poster: `GUSER${i}`, + title: `Task ${i}`, + description: `Description ${i}`, + reward: i * 1_000_000, + deadline: Math.floor(Date.now() / 1000) + 86400, + maxSubmissions: 1, + organizationId: orgResult.organization.id, + }); + expect(result.ok).toBe(true); + } + + const tasks = listTasks({ organizationId: orgResult.organization.id }); + expect(tasks.tasks).toHaveLength(taskCount); + }); + + it("lists all tasks for a given organization", () => { + const orgResult = createOrganization({ name: "Test Organization" }); + expect(orgResult.ok).toBe(true); + if (!orgResult.ok) return; + + createTask({ + poster: "GPOSTER1", + title: "Frontend Task", + description: "Build UI", + reward: 10_000_000, + deadline: Math.floor(Date.now() / 1000) + 86400, + maxSubmissions: 2, + organizationId: orgResult.organization.id, + }); + + createTask({ + poster: "GPOSTER2", + title: "Backend Task", + description: "Build API", + reward: 15_000_000, + deadline: Math.floor(Date.now() / 1000) + 86400, + maxSubmissions: 1, + organizationId: orgResult.organization.id, + }); + + createTask({ + poster: "GPOSTER3", + title: "Unrelated Task", + description: "Different org", + reward: 5_000_000, + deadline: Math.floor(Date.now() / 1000) + 86400, + maxSubmissions: 1, + organizationId: "", + }); + + const orgTasks = listTasks({ organizationId: orgResult.organization.id }); + expect(orgTasks.tasks).toHaveLength(2); + expect(orgTasks.tasks.every((t) => t.organizationId === orgResult.organization.id)).toBe( + true + ); + }); + + it("returns empty list when organization has no tasks", () => { + const orgResult = createOrganization({ name: "Empty Org" }); + expect(orgResult.ok).toBe(true); + if (!orgResult.ok) return; + + const tasks = listTasks({ organizationId: orgResult.organization.id }); + expect(tasks.tasks).toHaveLength(0); + }); + + it("handles tasks with non-existent organization ID", () => { + createTask({ + poster: "GBAD123", + title: "Orphaned Task", + description: "Task with invalid org ID", + reward: 1_000_000, + deadline: Math.floor(Date.now() / 1000) + 86400, + maxSubmissions: 1, + organizationId: "nonexistent", + }); + + const tasks = listTasks({ organizationId: "nonexistent" }); + expect(tasks.tasks).toHaveLength(1); + }); + }); + + describe("Organization Query Integration", () => { + it("combines organization filter with other filters", () => { + const orgResult = createOrganization({ name: "Filter Test Org" }); + expect(orgResult.ok).toBe(true); + if (!orgResult.ok) return; + + createTask({ + poster: "GUSER1", + title: "Low Reward Task", + description: "Easy task", + reward: 2_000_000, + deadline: Math.floor(Date.now() / 1000) + 86400, + maxSubmissions: 1, + difficulty: "beginner", + organizationId: orgResult.organization.id, + }); + + createTask({ + poster: "GUSER2", + title: "High Reward Task", + description: "Hard task", + reward: 20_000_000, + deadline: Math.floor(Date.now() / 1000) + 86400, + maxSubmissions: 1, + difficulty: "advanced", + organizationId: orgResult.organization.id, + }); + + const filtered = listTasks({ + organizationId: orgResult.organization.id, + minReward: 10_000_000, + }); + + expect(filtered.tasks).toHaveLength(1); + expect(filtered.tasks[0].title).toBe("High Reward Task"); + }); + }); +}); diff --git a/frontend/src/lib/organization-store.ts b/frontend/src/lib/organization-store.ts new file mode 100644 index 0000000..85ac95d --- /dev/null +++ b/frontend/src/lib/organization-store.ts @@ -0,0 +1,156 @@ +/** + * Organization Store + * + * In-memory storage and management for organizations that publish/manage tasks. + * Follows the same pattern as task-workflow.ts for consistency. + */ + +import type { Organization, CreateOrganizationInput } from "@/types/organization"; + +type OrganizationSuccess = { ok: true } & T; + +type OrganizationFailure = { + ok: false; + status: 400 | 404 | 409; + error: string; + details?: string[]; +}; + +export type OrganizationResult = OrganizationSuccess | OrganizationFailure; + +// In-memory storage +const organizations = new Map(); +const organizationsByName = new Map(); // name -> id mapping +let nextOrganizationId = 1; + +/** + * Validates organization creation input + */ +function validateCreateOrganizationInput(input: CreateOrganizationInput): string[] { + const errors: string[] = []; + + if (!input.name?.trim()) { + errors.push("Organization name is required."); + } + + if (input.name && input.name.trim().length < 2) { + errors.push("Organization name must be at least 2 characters."); + } + + if (input.name && input.name.trim().length > 100) { + errors.push("Organization name must not exceed 100 characters."); + } + + if (input.website && input.website.trim()) { + const urlPattern = /^https?:\/\/.+\..+/i; + if (!urlPattern.test(input.website.trim())) { + errors.push("Website must be a valid URL (http:// or https://)."); + } + } + + if (input.description && input.description.trim().length > 500) { + errors.push("Description must not exceed 500 characters."); + } + + return errors; +} + +/** + * Creates a new organization. + */ +export function createOrganization( + input: CreateOrganizationInput, + now: Date = new Date(), +): OrganizationResult<{ organization: Organization }> { + const errors = validateCreateOrganizationInput(input); + + if (errors.length > 0) { + return { + ok: false, + status: 400, + error: "Invalid organization data.", + details: errors, + }; + } + + const name = input.name.trim(); + const nameLower = name.toLowerCase(); + + // Check for duplicate name + if (organizationsByName.has(nameLower)) { + return { + ok: false, + status: 409, + error: "An organization with this name already exists.", + }; + } + + const id = String(nextOrganizationId++); + const organization: Organization = { + id, + name, + description: input.description?.trim() ?? "", + website: input.website?.trim() ?? "", + createdAt: now.toISOString(), + }; + + organizations.set(id, organization); + organizationsByName.set(nameLower, id); + + return { ok: true, organization }; +} + +/** + * Retrieves an organization by ID. + */ +export function getOrganization( + organizationId: string, +): OrganizationResult<{ organization: Organization }> { + const organization = organizations.get(organizationId); + + if (!organization) { + return { + ok: false, + status: 404, + error: "Organization not found.", + }; + } + + return { ok: true, organization: { ...organization } }; +} + +/** + * Retrieves an organization by name (case-insensitive). + */ +export function getOrganizationByName( + name: string, +): OrganizationResult<{ organization: Organization }> { + const nameLower = name.trim().toLowerCase(); + const organizationId = organizationsByName.get(nameLower); + + if (!organizationId) { + return { + ok: false, + status: 404, + error: "Organization not found.", + }; + } + + return getOrganization(organizationId); +} + +/** + * Lists all organizations. + */ +export function listOrganizations(): Organization[] { + return Array.from(organizations.values()).map((org) => ({ ...org })); +} + +/** + * Resets the organization store (for testing). + */ +export function resetOrganizationStore(): void { + organizations.clear(); + organizationsByName.clear(); + nextOrganizationId = 1; +} diff --git a/frontend/src/lib/task-listing.test.ts b/frontend/src/lib/task-listing.test.ts index 7532725..d062ed4 100644 --- a/frontend/src/lib/task-listing.test.ts +++ b/frontend/src/lib/task-listing.test.ts @@ -17,7 +17,7 @@ function seedTasks() { maxSubmissions: 3, difficulty: "advanced", technologies: ["Rust", "Soroban"], - organization: "Stellar Development Foundation", + organizationId: "org-sdf", }, new Date("2026-01-01T00:00:00.000Z"), ); @@ -32,7 +32,7 @@ function seedTasks() { maxSubmissions: 2, difficulty: "beginner", technologies: ["Figma", "CSS"], - organization: "Acme DAO", + organizationId: "org-acme", }, new Date("2026-01-02T00:00:00.000Z"), ); @@ -47,7 +47,7 @@ function seedTasks() { maxSubmissions: 1, difficulty: "intermediate", technologies: ["React", "TypeScript"], - organization: "Acme DAO", + organizationId: "org-acme", }, new Date("2026-01-03T00:00:00.000Z"), ); @@ -72,7 +72,7 @@ describe("listTasks", () => { if (!result.ok) return; expect(result.task.difficulty).toBe("intermediate"); expect(result.task.technologies).toEqual([]); - expect(result.task.organization).toBe(""); + expect(result.task.organizationId).toBe(""); }); it("returns all tasks with no filters, newest first by default", () => { @@ -115,10 +115,10 @@ describe("listTasks", () => { expect(result.tasks[0].title).toBe("Build a Soroban escrow contract"); }); - it("filters by organization (case-insensitive substring match)", () => { + it("filters by organization ID", () => { seedTasks(); - const result = listTasks({ organization: "acme" }); + const result = listTasks({ organizationId: "org-acme" }); expect(result.tasks).toHaveLength(2); expect(result.tasks.map((t) => t.title).sort()).toEqual( @@ -138,12 +138,12 @@ describe("listTasks", () => { it("combines multiple filters with AND semantics", () => { seedTasks(); - const result = listTasks({ organization: "acme", difficulty: "beginner" }); + const result = listTasks({ organizationId: "org-acme", difficulty: "beginner" }); expect(result.tasks).toHaveLength(1); expect(result.tasks[0].title).toBe("Design a landing page"); - const empty = listTasks({ organization: "acme", difficulty: "advanced" }); + const empty = listTasks({ organizationId: "org-acme", difficulty: "advanced" }); expect(empty.tasks).toHaveLength(0); expect(empty.total).toBe(0); }); @@ -233,7 +233,7 @@ describe("listTasks", () => { maxSubmissions: 1, difficulty: i % 2 === 0 ? "beginner" : "advanced", technologies: ["TypeScript"], - organization: "Bulk Org", + organizationId: "org-bulk", }); } diff --git a/frontend/src/lib/task-workflow.ts b/frontend/src/lib/task-workflow.ts index e1a3337..6ec5381 100644 --- a/frontend/src/lib/task-workflow.ts +++ b/frontend/src/lib/task-workflow.ts @@ -114,7 +114,7 @@ export function createTask( technologies: (input.technologies ?? []) .map((tech) => tech.trim()) .filter((tech) => tech.length > 0), - organization: input.organization?.trim() ?? "", + organizationId: input.organizationId?.trim() ?? "", }; tasks.set(id, task); @@ -157,7 +157,7 @@ export function getTask(taskId: string): WorkflowResult<{ task: TaskRecord }> { export function listTasks(query: ListTasksQuery = {}): ListTasksResult { const search = query.search?.trim().toLowerCase(); const technology = query.technology?.trim().toLowerCase(); - const organization = query.organization?.trim().toLowerCase(); + const organizationId = query.organizationId?.trim(); const filtered = Array.from(tasks.values()).filter((task) => { if (typeof query.minReward === "number" && task.reward < query.minReward) { @@ -175,7 +175,7 @@ export function listTasks(query: ListTasksQuery = {}): ListTasksResult { ) { return false; } - if (organization && !task.organization.toLowerCase().includes(organization)) { + if (organizationId && task.organizationId !== organizationId) { return false; } if (search) { diff --git a/frontend/src/types/organization.ts b/frontend/src/types/organization.ts new file mode 100644 index 0000000..3473991 --- /dev/null +++ b/frontend/src/types/organization.ts @@ -0,0 +1,19 @@ +/** + * Organization Types + * + * Represents organizations that publish or manage tasks/bounties. + */ + +export interface Organization { + id: string; + name: string; + description: string; + website: string; + createdAt: string; +} + +export interface CreateOrganizationInput { + name: string; + description?: string; + website?: string; +} diff --git a/frontend/src/types/task-workflow.ts b/frontend/src/types/task-workflow.ts index 3b07e17..16b9288 100644 --- a/frontend/src/types/task-workflow.ts +++ b/frontend/src/types/task-workflow.ts @@ -18,8 +18,8 @@ export interface TaskRecord { difficulty: TaskDifficulty; /** Technology/skill tags, e.g. ["Rust", "Soroban"]. */ technologies: string[]; - /** Name of the organization or team posting the bounty, if any. */ - organization: string; + /** ID of the organization posting the bounty, if any. */ + organizationId: string; } export interface SubmissionRecord { @@ -48,7 +48,7 @@ export interface CreateTaskInput { maxSubmissions: number; difficulty?: TaskDifficulty; technologies?: string[]; - organization?: string; + organizationId?: string; } export interface SubmitTaskInput { @@ -84,8 +84,8 @@ export interface ListTasksQuery { difficulty?: TaskDifficulty; /** Matches tasks whose technologies list includes this value (case-insensitive). */ technology?: string; - /** Case-insensitive substring match against organization. */ - organization?: string; + /** Filter by organization ID. */ + organizationId?: string; sort?: TaskSortOrder; /** 1-based page number. Defaults to 1. */ page?: number;