Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,5 @@ pnpm-debug.log*
Thumbs.db
.vscode/
.idea/
/frontend/.next
/frontend/.next
/frontend/tsconfig.tsbuildinfo
34 changes: 34 additions & 0 deletions docs/dynamodb-local-testing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# DynamoDB local smoke test

This test requires neither the word lookup Lambda nor AWS credentials. It creates two local tables, writes a typed `BaseCard` as the lookup Lambda would, saves a `UserCard`, prints DynamoDB's wrapped attributes, and resets the card.

Start DynamoDB Local with Docker:

```powershell
docker run --rm -p 8000:8000 amazon/dynamodb-local
```

In a second terminal:

```powershell
cd frontend
$env:DYNAMODB_ENDPOINT = 'http://localhost:8000'
npm run dynamodb:smoke
```

The sample Lambda-shaped response is in `frontend/lib/test-data/example-base-card.ts`. A real lookup Lambda must return the same `BaseCard` contract, wrapped as `{ "card": baseCard }`; the frontend and save endpoint use that type directly.

The smoke-test output shows `S`, `N`, `M`, and `L` wrappers. For example, `content` is an `M`, `definitions` is an `L`, and each definition's `id` and `text` are `S` values.

## Boto3 direct insertion

With DynamoDB Local still running, install Boto3 once and run the independent Python test:

```powershell
cd frontend
py -3.12 -m pip install boto3
$env:DYNAMODB_ENDPOINT = 'http://127.0.0.1:8000'
py -3.12 scripts/dynamodb_boto3_smoke.py
```

This uses Boto3's low-level `put_item` API and explicitly produces DynamoDB `AttributeValue` wrappers instead of relying on Boto3's higher-level resource serializer.
9 changes: 9 additions & 0 deletions frontend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,12 @@ POST_LOGIN_REDIRECT=/
POST_LOGOUT_REDIRECT=/

NEXT_PUBLIC_API_URL=http://localhost:3000/api

# Server-only DynamoDB configuration. Credentials come from your AWS profile
# locally or the deployed runtime's IAM role; do not expose them with NEXT_PUBLIC_.
AWS_REGION=us-east-1
USER_CARDS_TABLE_NAME=UserCards
BASE_CARDS_TABLE_NAME=BaseCards

# Local-only, for `npm run dynamodb:smoke` with DynamoDB Local.
# DYNAMODB_ENDPOINT=http://localhost:8000
22 changes: 22 additions & 0 deletions frontend/app/api/cards/[cardId]/reset/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from '@/lib/auth-session'
import { resetUserCard } from '@/lib/server/user-cards'

export const runtime = 'nodejs'

// POST /api/cards/:cardId/reset
export async function POST(_req: NextRequest, { params }: { params: { cardId: string } }) {
const session = await getServerSession()
if (!session.authenticated || !session.user.openId) {
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
}

try {
const card = await resetUserCard(session.user.openId, params.cardId)
if (!card) return NextResponse.json({ error: 'Card not found' }, { status: 404 })
return NextResponse.json({ card })
} catch (error) {
console.error('Unable to reset user card', error)
return NextResponse.json({ error: 'Unable to reset card' }, { status: 500 })
}
}
37 changes: 37 additions & 0 deletions frontend/app/api/cards/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from '@/lib/auth-session'
import { saveUserCard } from '@/lib/server/user-cards'
import type { BaseCard } from '@/types/vocabulary'

export const runtime = 'nodejs'

function isBaseCard(value: unknown): value is BaseCard {
if (!value || typeof value !== 'object') return false
const card = value as Partial<BaseCard>
return Boolean(
card.baseCardId && card.language && card.lemma && card.normalizedLemma &&
Array.isArray(card.definitions) && Array.isArray(card.examples) && card.metadata
)
}

// POST /api/cards — save a lookup result without calling a Lambda.
export async function POST(req: NextRequest) {
const session = await getServerSession()
if (!session.authenticated || !session.user.openId) {
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
}

const body: unknown = await req.json().catch(() => null)
const request = body as { baseCard?: unknown; notes?: unknown } | null
if (!request || !isBaseCard(request.baseCard) || (request.notes !== undefined && typeof request.notes !== 'string')) {
return NextResponse.json({ error: 'Expected { baseCard, notes? }' }, { status: 400 })
}

try {
const card = await saveUserCard(session.user.openId, request.baseCard, request.notes)
return NextResponse.json({ card }, { status: 201 })
} catch (error) {
console.error('Unable to save user card', error)
return NextResponse.json({ error: 'Unable to save card' }, { status: 500 })
}
}
27 changes: 10 additions & 17 deletions frontend/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,12 @@ import { motion, AnimatePresence } from 'framer-motion'
import Navigation from '@/components/Navigation'
import VocabularyCard from '@/components/VocabularyCard'
import ChatAssistant from '@/components/ChatAssistant'
import type { BaseCard } from '@/types/vocabulary'

export default function LookupPage() {
const [searchQuery, setSearchQuery] = useState('')
const [selectedLanguage, setSelectedLanguage] = useState('')
const [searchResult, setSearchResult] = useState<any>(null)
const [searchResult, setSearchResult] = useState<BaseCard | null>(null)
const [isSearching, setIsSearching] = useState(false)
const [showChat, setShowChat] = useState(false)

Expand All @@ -24,15 +25,7 @@ const languageCodes: Record<string, string> = {
setIsSearching(true)
try {
const data = await apiClient.lookup({ lang: languageCodes[selectedLanguage], lemma: searchQuery.trim() })
const card = data.card
setSearchResult({
word: card.lemma || searchQuery,
language: selectedLanguage,
partOfSpeech: card.partOfSpeech,
definitions: [card.shortDefinition],
examples: card.examples.map((e: { src: string; tgt: string }) => ({ src: e.src, tgt: e.tgt })),
relatedWords: card.relatedWords || []
})
setSearchResult(data.card)
} catch {
alert('Word not found. Try another word.')
} finally {
Expand Down Expand Up @@ -177,12 +170,12 @@ const languageCodes: Record<string, string> = {
transition={{ duration: 0.5 }}
>
<VocabularyCard
word={searchResult.word}
language={searchResult.language}
definitions={searchResult.definitions}
examples={searchResult.examples}
relatedWords={searchResult.relatedWords}
partOfSpeech={searchResult.partOfSpeech}
word={searchResult.lemma}
language={searchResult.language}
definitions={searchResult.definitions.map((definition) => definition.text)}
examples={searchResult.examples.map((example) => ({ src: example.source, tgt: example.translation }))}
relatedWords={(searchResult.relatedWords || []).map((relatedWord) => relatedWord.lemma)}
partOfSpeech={searchResult.definitions[0]?.partOfSpeech}
onSave={handleSave}
/>

Expand Down Expand Up @@ -244,7 +237,7 @@ const languageCodes: Record<string, string> = {
<AnimatePresence>
{showChat && searchResult && (
<ChatAssistant
word={searchResult.word}
word={searchResult.lemma}
onClose={() => setShowChat(false)}
/>
)}
Expand Down
61 changes: 40 additions & 21 deletions frontend/lib/api.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// lib/api.ts
// API utilities for connecting to the backend Lambda functions
// API utilities for connecting to the backend Lambda functions
import type { BaseCard, CardLookupResponse, UserCard } from '@/types/vocabulary'

const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000/api'

Expand All @@ -8,15 +9,15 @@ export interface LookupParams {
lemma: string
}

export interface CreateCardParams extends LookupParams {
card: any
notes?: string
}
export interface CreateCardParams {
baseCard: BaseCard
notes?: string
}

export interface ChatParams {
word: string
baseCard: any
userEdits?: any
export interface ChatParams {
word: string
baseCard: BaseCard
userEdits?: Partial<UserCard['content']>
notes?: string
question: string
}
Expand All @@ -33,7 +34,7 @@ class ApiClient {
this.token = token
}

private async request(endpoint: string, options: RequestInit = {}) {
private async request<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
const headers: HeadersInit = {
'Content-Type': 'application/json',
...options.headers,
Expand All @@ -52,36 +53,54 @@ class ApiClient {
throw new Error(`API error: ${response.status} ${response.statusText}`)
}

return response.json()
return response.json() as Promise<T>
}

// GET /cardLookup?lang=&lemma=
async lookup({ lang, lemma }: LookupParams) {
return this.request(`/cardLookup?lang=${lang}&lemma=${encodeURIComponent(lemma)}`)
async lookup({ lang, lemma }: LookupParams): Promise<CardLookupResponse> {
return this.request<CardLookupResponse>(`/cardLookup?lang=${encodeURIComponent(lang)}&lemma=${encodeURIComponent(lemma)}`)
}

// GET /allCards (list user cards)
async getCards(userId: string) {
return this.request('/allCards', {
async getCards(userId: string): Promise<{ cards: UserCard[] }> {
return this.request<{ cards: UserCard[] }>('/allCards', {
headers: { 'x-user-id': userId }
})
}

// POST /chatNuance (nuance Q&A for a word/card)
async chat(params: ChatParams) {
return this.request('/chatNuance', {
async chat(params: ChatParams): Promise<{ response: string }> {
return this.request<{ response: string }>('/chatNuance', {
method: 'POST',
body: JSON.stringify(params),
})
}

// DELETE /cards/:cardId
async deleteCard(cardId: string) {
return this.request(`/cards/${cardId}`, {
async deleteCard(cardId: string): Promise<void> {
return this.request<void>(`/cards/${encodeURIComponent(cardId)}`, {
method: 'DELETE',
})
}
}
}

// POST /cards — create a user-owned card from a BaseCard lookup result.
async saveCard(params: CreateCardParams): Promise<{ card: UserCard }> {
return this.request<{ card: UserCard }>('/cards', {
method: 'POST',
body: JSON.stringify(params),
})
}

/**
* Restores user-editable learning content from the referenced BaseCard.
* The backend preserves cardId, userId, createdAt, and baseRef.
*/
async resetCard(cardId: string): Promise<{ card: UserCard }> {
return this.request<{ card: UserCard }>(`/cards/${encodeURIComponent(cardId)}/reset`, {
method: 'POST',
})
}
}

export const apiClient = new ApiClient(API_BASE_URL)

Expand Down
65 changes: 65 additions & 0 deletions frontend/lib/server/dynamodb.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import {
DynamoDBClient,
type AttributeValue,
} from '@aws-sdk/client-dynamodb'

/**
* Converts application JSON to DynamoDB's low-level AttributeValue format.
* Objects become M (map), arrays become L (list), strings become S, numbers
* become N, and booleans become BOOL. Undefined object properties are omitted.
*/
export function toAttributeValue(value: unknown): AttributeValue {
if (value === null) return { NULL: true }
if (typeof value === 'string') return { S: value }
if (typeof value === 'number') {
if (!Number.isFinite(value)) throw new TypeError('DynamoDB numbers must be finite')
return { N: String(value) }
}
if (typeof value === 'boolean') return { BOOL: value }
if (Array.isArray(value)) return { L: value.map(toAttributeValue) }
if (typeof value === 'object') {
const map: Record<string, AttributeValue> = {}
for (const [key, entry] of Object.entries(value)) {
if (entry !== undefined) map[key] = toAttributeValue(entry)
}
return { M: map }
}
throw new TypeError(`Unsupported DynamoDB value: ${typeof value}`)
}

export function toItem(value: object): Record<string, AttributeValue> {
const item: Record<string, AttributeValue> = {}
for (const [key, entry] of Object.entries(value)) {
if (entry !== undefined) item[key] = toAttributeValue(entry)
}
return item
}

export function fromAttributeValue(value: AttributeValue): unknown {
if (value.S !== undefined) return value.S
if (value.N !== undefined) return Number(value.N)
if (value.BOOL !== undefined) return value.BOOL
if (value.NULL) return null
if (value.L) return value.L.map(fromAttributeValue)
if (value.M) return fromItem(value.M)
if (value.SS) return value.SS
if (value.NS) return value.NS.map(Number)
throw new TypeError('Unsupported DynamoDB AttributeValue')
}

export function fromItem<T>(item: Record<string, AttributeValue>): T {
return Object.fromEntries(Object.entries(item).map(([key, value]) => [key, fromAttributeValue(value)])) as T
}

let client: DynamoDBClient | undefined

export function getDynamoClient() {
// The AWS SDK uses the standard AWS credential provider chain. This works
// with local AWS profiles as well as deployed IAM roles without app changes.
client ??= new DynamoDBClient({
region: process.env.AWS_REGION || 'us-east-1',
// DynamoDB Local is opt-in, so production continues to use AWS's endpoint.
endpoint: process.env.DYNAMODB_ENDPOINT?.trim() || undefined,
})
return client
}
Loading