diff --git a/app/app/api/cards/[cardId]/route.ts b/app/app/api/cards/[cardId]/route.ts new file mode 100644 index 0000000..09315f9 --- /dev/null +++ b/app/app/api/cards/[cardId]/route.ts @@ -0,0 +1,162 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCardStorage } from "@/lib/services/cardStorage"; +import { createBaseCard, validateCardInput } from "@/lib/services/cardTemplating"; + +/** + * GET /api/cards/[cardId] + * Retrieves a specific card by ID. + */ +export async function GET( + request: NextRequest, + { params }: { params: { cardId: string } } +) { + try { + const userId = request.headers.get("x-user-id") ?? "demo-user"; + + if (!userId) { + return NextResponse.json( + { error: "Unauthorized" }, + { status: 401 } + ); + } + + const storage = getCardStorage(); + const card = await storage.getCard(userId, params.cardId); + + if (!card) { + return NextResponse.json( + { error: "Card not found" }, + { status: 404 } + ); + } + + return NextResponse.json( + { + success: true, + card + }, + { status: 200 } + ); + + } catch (error) { + console.error("Error fetching card:", error); + return NextResponse.json( + { + error: "Failed to fetch card", + details: error instanceof Error ? error.message : "Unknown error" + }, + { status: 500 } + ); + } +} + +/** + * PUT /api/cards/[cardId] + * Updates an existing card (creates a new version). + */ +export async function PUT( + request: NextRequest, + { params }: { params: { cardId: string } } +) { + try { + const userId = request.headers.get("x-user-id") ?? "demo-user"; + + if (!userId) { + return NextResponse.json( + { error: "Unauthorized" }, + { status: 401 } + ); + } + + const storage = getCardStorage(); + const existingCard = await storage.getCard(userId, params.cardId); + + if (!existingCard) { + return NextResponse.json( + { error: "Card not found" }, + { status: 404 } + ); + } + + const body = await request.json(); + + // Validate input + try { + validateCardInput(body); + } catch (validationError) { + return NextResponse.json( + { + error: "Invalid input", + details: validationError instanceof Error ? validationError.message : "Unknown error" + }, + { status: 400 } + ); + } + + // Create new version of the card with the same ID + const updatedCard = createBaseCard(body); + updatedCard.cardId = params.cardId; // Preserve the original card ID + + await storage.updateCard(userId, updatedCard); + + return NextResponse.json( + { + success: true, + card: updatedCard, + message: "Card updated successfully" + }, + { status: 200 } + ); + + } catch (error) { + console.error("Error updating card:", error); + return NextResponse.json( + { + error: "Failed to update card", + details: error instanceof Error ? error.message : "Unknown error" + }, + { status: 500 } + ); + } +} + +/** + * DELETE /api/cards/[cardId] + * Deletes a card from the user's profile. + */ +export async function DELETE( + request: NextRequest, + { params }: { params: { cardId: string } } +) { + try { + const userId = request.headers.get("x-user-id") ?? "demo-user"; + + if (!userId) { + return NextResponse.json( + { error: "Unauthorized" }, + { status: 401 } + ); + } + + const storage = getCardStorage(); + await storage.deleteCard(userId, params.cardId); + + return NextResponse.json( + { + success: true, + message: "Card deleted successfully" + }, + { status: 200 } + ); + + } catch (error) { + console.error("Error deleting card:", error); + return NextResponse.json( + { + error: "Failed to delete card", + details: error instanceof Error ? error.message : "Unknown error" + }, + { status: 500 } + ); + } +} diff --git a/app/app/api/cards/route.ts b/app/app/api/cards/route.ts new file mode 100644 index 0000000..642ca17 --- /dev/null +++ b/app/app/api/cards/route.ts @@ -0,0 +1,105 @@ +import { NextRequest, NextResponse } from "next/server"; +import { createBaseCard, validateCardInput } from "@/lib/services/cardTemplating"; +import { getCardStorage } from "@/lib/services/cardStorage"; + +/** + * POST /api/cards + * Creates a new card and saves it to the user's profile. + * + * Request body should match BaseCardInput interface. + * Returns the created card with its generated ID. + */ +export async function POST(request: NextRequest) { + try { + // TODO: Replace with actual auth - get userId from session + const userId = request.headers.get("x-user-id") ?? "demo-user"; + + if (!userId) { + return NextResponse.json( + { error: "Unauthorized" }, + { status: 401 } + ); + } + + const body = await request.json(); + + // Validate input + try { + validateCardInput(body); + } catch (validationError) { + return NextResponse.json( + { + error: "Invalid input", + details: validationError instanceof Error ? validationError.message : "Unknown error" + }, + { status: 400 } + ); + } + + // Create the card + const card = createBaseCard(body); + + // Save to storage + const storage = getCardStorage(); + await storage.saveCard(userId, card); + + return NextResponse.json( + { + success: true, + card, + message: "Card created successfully" + }, + { status: 201 } + ); + + } catch (error) { + console.error("Error creating card:", error); + return NextResponse.json( + { + error: "Failed to create card", + details: error instanceof Error ? error.message : "Unknown error" + }, + { status: 500 } + ); + } +} + +/** + * GET /api/cards + * Retrieves all cards for the authenticated user. + */ +export async function GET(request: NextRequest) { + try { + // TODO: Replace with actual auth + const userId = request.headers.get("x-user-id") ?? "demo-user"; + + if (!userId) { + return NextResponse.json( + { error: "Unauthorized" }, + { status: 401 } + ); + } + + const storage = getCardStorage(); + const cards = await storage.getUserCards(userId); + + return NextResponse.json( + { + success: true, + cards, + count: cards.length + }, + { status: 200 } + ); + + } catch (error) { + console.error("Error fetching cards:", error); + return NextResponse.json( + { + error: "Failed to fetch cards", + details: error instanceof Error ? error.message : "Unknown error" + }, + { status: 500 } + ); + } +} diff --git a/app/lib/services/README.md b/app/lib/services/README.md new file mode 100644 index 0000000..44ab1e1 --- /dev/null +++ b/app/lib/services/README.md @@ -0,0 +1,76 @@ +# Card Templating & Storage Services + +This directory contains the core services for creating and persisting language learning cards. + +## Quick Start + +```typescript +import { createBaseCard } from "./cardTemplating"; +import { getCardStorage } from "./cardStorage"; + +// 1. Create a card from AI-generated data +const card = createBaseCard({ + language: "Spanish", + lemma: "hablar", + partOfSpeech: "verb", + definitions: [ + { definition: "to speak, to talk" } + ], + examples: [ + { sentence: "Ella habla tres idiomas." } + ], + ipa: "aˈβlaɾ" +}); + +// 2. Save to user profile +const storage = getCardStorage(); +await storage.saveCard("user-123", card); +``` + +## Architecture + +### cardTemplating.ts +- **`createBaseCard(input)`** - Templates linguistic data into standardized JSON +- **`validateCardInput(input)`** - Validates input before card creation +- **`createBaseCardBatch(inputs)`** - Bulk card creation + +### cardStorage.ts +- **Abstract interface** - `CardStorageProvider` for database-agnostic persistence +- **In-memory implementation** - For testing and development +- **Factory function** - `getCardStorage()` returns configured provider + +## API Routes + +- `POST /api/cards` - Create new card +- `GET /api/cards` - Get all user's cards +- `GET /api/cards/[cardId]` - Get specific card +- `PUT /api/cards/[cardId]` - Update card (versioned) +- `DELETE /api/cards/[cardId]` - Delete card + +## Best Practices + +✅ **DO:** +- Keep cards immutable once learned +- Version cards instead of regenerating IDs +- Validate input before card creation +- Use the abstract storage interface for flexibility +- Attach learning progress separately from cards + +❌ **DON'T:** +- Let LLMs write directly to database +- Regenerate card IDs on edits +- Store progress data inside cards +- Skip input validation + +## Testing + +```typescript +import { InMemoryCardStorage } from "./cardStorage"; + +const storage = new InMemoryCardStorage(); +const card = createBaseCard(testInput); +await storage.saveCard("test-user", card); + +const retrieved = await storage.getCard("test-user", card.cardId); +expect(retrieved).toEqual(card); +``` diff --git a/app/lib/services/cardStorage.ts b/app/lib/services/cardStorage.ts new file mode 100644 index 0000000..a5f0a5b --- /dev/null +++ b/app/lib/services/cardStorage.ts @@ -0,0 +1,92 @@ +import type { BaseCard } from "../types/card.types"; + +/** + * Abstract storage interface for card persistence. + * Implement this interface with your actual database (DynamoDB, PostgreSQL, etc.) + */ +export interface CardStorageProvider { + /** + * Save a card to a user's profile + */ + saveCard(userId: string, card: BaseCard): Promise; + + /** + * Get all cards for a user + */ + getUserCards(userId: string): Promise; + + /** + * Get a specific card by ID + */ + getCard(userId: string, cardId: string): Promise; + + /** + * Delete a card from a user's profile + */ + deleteCard(userId: string, cardId: string): Promise; + + /** + * Update an existing card (versioning) + */ + updateCard(userId: string, card: BaseCard): Promise; +} + +/** + * In-memory storage implementation for testing and development. + * DO NOT use in production. + */ +export class InMemoryCardStorage implements CardStorageProvider { + private storage: Map> = new Map(); + + async saveCard(userId: string, card: BaseCard): Promise { + if (!this.storage.has(userId)) { + this.storage.set(userId, new Map()); + } + + this.storage.get(userId)!.set(card.cardId, card); + } + + async getUserCards(userId: string): Promise { + const userCards = this.storage.get(userId); + if (!userCards) return []; + + return Array.from(userCards.values()); + } + + async getCard(userId: string, cardId: string): Promise { + return this.storage.get(userId)?.get(cardId) ?? null; + } + + async deleteCard(userId: string, cardId: string): Promise { + this.storage.get(userId)?.delete(cardId); + } + + async updateCard(userId: string, card: BaseCard): Promise { + const updatedCard = { + ...card, + version: incrementVersion(card.version) + }; + + await this.saveCard(userId, updatedCard); + } +} + +/** + * Increments a semantic version string (e.g., "1.0" -> "1.1") + */ +function incrementVersion(version: string): string { + const parts = version.split("."); + const minor = parseInt(parts[1] ?? "0", 10) + 1; + return `${parts[0]}.${minor}`; +} + +/** + * Factory function to get the appropriate storage provider. + * Currently returns in-memory storage for development. + * Can be extended to support DynamoDB or other backends. + */ +export function getCardStorage(): CardStorageProvider { + // For now, always use in-memory storage + // To add DynamoDB support, install @aws-sdk packages and configure via env vars + return new InMemoryCardStorage(); +} diff --git a/app/lib/services/cardTemplating.ts b/app/lib/services/cardTemplating.ts new file mode 100644 index 0000000..d74dcdc --- /dev/null +++ b/app/lib/services/cardTemplating.ts @@ -0,0 +1,130 @@ +import { randomUUID } from "crypto"; +import type { BaseCardInput, BaseCard } from "../types/card.types"; + +/** + * Creates a fully-formed baseCard from validated input data. + * This function templates linguistic data into a standardized JSON structure. + * + * @param input - Validated linguistic data from AI pipeline or upstream service + * @returns A complete BaseCard object ready for persistence + */ +export function createBaseCard(input: BaseCardInput): BaseCard { + const now = new Date().toISOString(); + + return { + cardId: randomUUID(), + language: input.language, + lemma: input.lemma, + normalizedLemma: input.lemma.toLowerCase().trim(), + + partOfSpeech: input.partOfSpeech, + otherForms: [], + + phonetics: { + ipa: input.ipa ?? "", + respelling: "" + }, + + definitions: input.definitions.map(d => ({ + definition: d.definition, + register: d.register ?? "neutral", + domain: d.domain ?? "general", + confidence: 0.9 + })), + + coreMeaning: input.definitions[0]?.definition ?? "", + + examples: input.examples.map(e => ({ + sentence: e.sentence, + highlightedLemma: input.lemma, + difficulty: e.difficulty ?? "medium", + contextTag: e.contextTag ?? "daily", + sourceType: "constructed" + })), + + collocations: [], + + synonyms: (input.synonyms ?? []).map(word => ({ + word, + nuance: "" + })), + + antonyms: [], + + usageNotes: [], + + semanticRelations: { + hypernyms: [], + hyponyms: [], + relatedConcepts: [] + }, + + etymology: { + origin: "", + evolution: "" + }, + + voiceout: { + ttsText: input.lemma, + slowTtsText: input.lemma, + pronunciationHint: "" + }, + + learningAids: { + mnemonic: "", + visualCue: "" + }, + + aiGrounding: { + allowedScope: "Only answer questions using this card's data and general linguistic knowledge.", + ambiguityNotes: "" + }, + + version: "1.0", + createdBy: "ai", + qualityScore: 0.85, + createdAt: now + }; +} + +/** + * Validates input data before card creation. + * Throws descriptive errors if validation fails. + * + * @param input - Data to validate + * @throws Error if validation fails + */ +export function validateCardInput(input: unknown): asserts input is BaseCardInput { + const data = input as Partial; + + if (!data.language || typeof data.language !== "string") { + throw new Error("Invalid or missing language"); + } + + if (!data.lemma || typeof data.lemma !== "string") { + throw new Error("Invalid or missing lemma"); + } + + if (!data.partOfSpeech || typeof data.partOfSpeech !== "string") { + throw new Error("Invalid or missing part of speech"); + } + + if (!Array.isArray(data.definitions) || data.definitions.length === 0) { + throw new Error("Definitions must be a non-empty array"); + } + + if (!Array.isArray(data.examples) || data.examples.length === 0) { + throw new Error("Examples must be a non-empty array"); + } +} + +/** + * Creates multiple cards in batch from an array of inputs. + * Useful for bulk import operations. + * + * @param inputs - Array of card inputs + * @returns Array of created cards + */ +export function createBaseCardBatch(inputs: BaseCardInput[]): BaseCard[] { + return inputs.map(input => createBaseCard(input)); +} diff --git a/app/lib/types/card.types.ts b/app/lib/types/card.types.ts new file mode 100644 index 0000000..95b6f63 --- /dev/null +++ b/app/lib/types/card.types.ts @@ -0,0 +1,114 @@ +/** + * Input data structure for creating base cards + */ +export interface BaseCardInput { + language: string; + lemma: string; + partOfSpeech: string; + definitions: Array<{ + definition: string; + register?: string; + domain?: string; + }>; + examples: Array<{ + sentence: string; + difficulty?: "easy" | "medium" | "hard"; + contextTag?: string; + }>; + ipa?: string; + synonyms?: string[]; +} + +/** + * Complete base card schema + */ +export interface BaseCard { + cardId: string; + language: string; + lemma: string; + normalizedLemma: string; + + partOfSpeech: string; + otherForms: string[]; + + phonetics: { + ipa: string; + respelling: string; + }; + + definitions: Array<{ + definition: string; + register: string; + domain: string; + confidence: number; + }>; + + coreMeaning: string; + + examples: Array<{ + sentence: string; + highlightedLemma: string; + difficulty: "easy" | "medium" | "hard"; + contextTag: string; + sourceType: string; + }>; + + collocations: Array<{ + phrase: string; + context?: string; + }>; + + synonyms: Array<{ + word: string; + nuance: string; + }>; + + antonyms: Array<{ + word: string; + nuance: string; + }>; + + usageNotes: string[]; + + semanticRelations: { + hypernyms: string[]; + hyponyms: string[]; + relatedConcepts: string[]; + }; + + etymology: { + origin: string; + evolution: string; + }; + + voiceout: { + ttsText: string; + slowTtsText: string; + pronunciationHint: string; + }; + + learningAids: { + mnemonic: string; + visualCue: string; + }; + + aiGrounding: { + allowedScope: string; + ambiguityNotes: string; + }; + + version: string; + createdBy: string; + qualityScore: number; + createdAt: string; +} + +/** + * User profile structure with cards + */ +export interface UserProfile { + userId: string; + cards: Record; + updatedAt: string; + createdAt: string; +} diff --git a/app/package-lock.json b/app/package-lock.json index 479a198..891d07b 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -64,6 +64,7 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -1460,6 +1461,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.10.tgz", "integrity": "sha512-WPigyYuGhgZ/cTPRXB2EwUw+XvsRA3GqHlsP4qteqrnnjDrApbS7MxcGr/hke5iUoeB7E/gQtrs9I37zAJ0Vjw==", "dev": true, + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -1515,6 +1517,7 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.54.0.tgz", "integrity": "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==", "dev": true, + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.54.0", "@typescript-eslint/types": "8.54.0", @@ -1983,6 +1986,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2300,6 +2304,7 @@ "url": "https://github.com/sponsors/ai" } ], + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -2829,6 +2834,7 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz", "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -3006,6 +3012,7 @@ "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -5038,6 +5045,7 @@ "version": "19.2.3", "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -5046,6 +5054,7 @@ "version": "19.2.3", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -5691,6 +5700,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, + "peer": true, "engines": { "node": ">=12" }, @@ -5842,6 +5852,7 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -6102,6 +6113,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "dev": true, + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" }