6 plans across 3 waves covering market-aware pricing, exchange rates, community price data, and currency-normalized display. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
224 lines
11 KiB
Markdown
224 lines
11 KiB
Markdown
---
|
|
phase: 33-currency-system
|
|
plan: 04
|
|
type: execute
|
|
wave: 2
|
|
depends_on: [01, 02]
|
|
files_modified:
|
|
- src/server/services/community-price.service.ts
|
|
- src/server/routes/community-prices.ts
|
|
- src/server/services/setup.service.ts
|
|
- src/server/services/totals.service.ts
|
|
- src/server/index.ts
|
|
- tests/services/community-price.service.test.ts
|
|
autonomous: true
|
|
requirements: [D-03, D-04, D-05, D-07, D-21]
|
|
|
|
must_haves:
|
|
truths:
|
|
- "Users can submit community prices for items they own"
|
|
- "Community price submissions are tied to collection ownership"
|
|
- "Community price aggregation returns per-market median and report count"
|
|
- "Setup totals handle items with the same currency correctly"
|
|
artifacts:
|
|
- path: "src/server/services/community-price.service.ts"
|
|
provides: "Community price submission, ownership validation, aggregation"
|
|
exports: ["submitCommunityPrice", "getCommunityPriceStats", "validateOwnership"]
|
|
- path: "src/server/routes/community-prices.ts"
|
|
provides: "Community price API endpoints"
|
|
- path: "tests/services/community-price.service.test.ts"
|
|
provides: "Community price service tests"
|
|
min_lines: 40
|
|
key_links:
|
|
- from: "src/server/services/community-price.service.ts"
|
|
to: "src/db/schema.ts"
|
|
via: "Drizzle queries on communityPrices + items tables"
|
|
pattern: "communityPrices"
|
|
- from: "src/server/routes/community-prices.ts"
|
|
to: "src/server/services/community-price.service.ts"
|
|
via: "route handler calls service"
|
|
pattern: "submitCommunityPrice|getCommunityPriceStats"
|
|
---
|
|
|
|
<objective>
|
|
Create community price submission system with ownership validation and per-market aggregation, plus update setup/totals services for currency awareness.
|
|
|
|
Purpose: Enable community price data (D-04, D-05, D-21) and ensure setup totals work correctly with currency metadata.
|
|
Output: Community price API, aggregation queries, updated setup/totals services.
|
|
</objective>
|
|
|
|
<execution_context>
|
|
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
|
@$HOME/.claude/get-shit-done/templates/summary.md
|
|
</execution_context>
|
|
|
|
<context>
|
|
@.planning/PROJECT.md
|
|
@.planning/ROADMAP.md
|
|
@.planning/STATE.md
|
|
@.planning/phases/33-currency-system/33-CONTEXT.md
|
|
@.planning/phases/33-currency-system/33-01-SUMMARY.md
|
|
|
|
<interfaces>
|
|
From src/db/schema.ts (Plan 01):
|
|
```typescript
|
|
export const communityPrices = pgTable("community_prices", {
|
|
id: serial("id").primaryKey(),
|
|
globalItemId: integer("global_item_id").notNull().references(() => globalItems.id, { onDelete: "cascade" }),
|
|
userId: integer("user_id").notNull().references(() => users.id),
|
|
market: text("market").notNull(),
|
|
currency: text("currency").notNull(),
|
|
priceCents: integer("price_cents").notNull(),
|
|
priceDate: timestamp("price_date"),
|
|
sourceType: text("source_type").notNull(), // 'purchased' | 'researched'
|
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
|
}, (table) => [unique().on(table.globalItemId, table.userId, table.sourceType)]);
|
|
```
|
|
|
|
From src/server/services/setup.service.ts (existing):
|
|
```typescript
|
|
export async function getAllSetups(db: Db, userId: number) { ... }
|
|
// Uses SQL: SUM(COALESCE(global_items.price_cents, items.price_cents) * items.quantity)
|
|
export async function getSetupWithItems(db: Db, userId: number, setupId: number) { ... }
|
|
```
|
|
|
|
From src/server/services/totals.service.ts (existing):
|
|
```typescript
|
|
export async function getCategoryTotals(db: Db, userId: number) { ... }
|
|
export async function getGlobalTotals(db: Db, userId: number) { ... }
|
|
```
|
|
</interfaces>
|
|
</context>
|
|
|
|
<tasks>
|
|
|
|
<task type="auto" tdd="true">
|
|
<name>Task 1: Create community price service with ownership validation and aggregation</name>
|
|
<files>src/server/services/community-price.service.ts, src/server/routes/community-prices.ts, src/server/index.ts, tests/services/community-price.service.test.ts</files>
|
|
<read_first>src/server/services/item.service.ts, src/server/routes/items.ts, src/server/index.ts, src/db/schema.ts</read_first>
|
|
<behavior>
|
|
- validateOwnership(db, userId, globalItemId) returns true if user has an item with that globalItemId
|
|
- validateOwnership(db, userId, globalItemId) returns false if user does not own the item
|
|
- submitCommunityPrice(db, data) creates/updates a community price (ON CONFLICT upsert)
|
|
- submitCommunityPrice returns null if ownership validation fails
|
|
- getCommunityPriceStats(db, globalItemId, market?) returns { market, currency, medianPrice, reportCount }[]
|
|
- getCommunityPriceStats filters by market when market param provided
|
|
- Stats only returned when reportCount >= 3 (minimum threshold per D-21)
|
|
- POST /api/community-prices requires auth
|
|
- GET /api/community-prices/:globalItemId returns aggregated stats (public)
|
|
</behavior>
|
|
<action>
|
|
Create `src/server/services/community-price.service.ts`:
|
|
|
|
Per D-05: `validateOwnership(db, userId, globalItemId)`:
|
|
- SELECT COUNT(*) FROM items WHERE user_id = $1 AND global_item_id = $2
|
|
- Return count > 0
|
|
|
|
Per D-04, D-05: `submitCommunityPrice(db, { globalItemId, userId, market, currency, priceCents, priceDate, sourceType })`:
|
|
- First call validateOwnership — if false, return null (user doesn't own this item)
|
|
- INSERT INTO community_prices ... ON CONFLICT (global_item_id, user_id, source_type) DO UPDATE SET price_cents = EXCLUDED.price_cents, price_date = EXCLUDED.price_date, market = EXCLUDED.market, currency = EXCLUDED.currency
|
|
- Return the upserted row
|
|
|
|
Per D-21: `getCommunityPriceStats(db, globalItemId, market?)`:
|
|
- Use PostgreSQL PERCENTILE_CONT(0.5) for median calculation
|
|
- Query: SELECT market, currency, PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY price_cents) as median_price, COUNT(*) as report_count FROM community_prices WHERE global_item_id = $1 [AND market = $2] GROUP BY market, currency HAVING COUNT(*) >= 3
|
|
- Return array of { market, currency, medianPrice (integer cents), reportCount }
|
|
|
|
Create `src/server/routes/community-prices.ts`:
|
|
- `GET /:globalItemId`: Call getCommunityPriceStats(db, id), return JSON
|
|
- `POST /`: Require auth. Validate body: `{ globalItemId: z.number().int(), market: z.string(), currency: z.string().max(3), priceCents: z.number().int().nonnegative(), priceDate: z.string().datetime().optional(), sourceType: z.enum(["purchased", "researched"]) }`. Call submitCommunityPrice. Return 403 if ownership validation fails, 200 with data otherwise.
|
|
|
|
Register in `src/server/index.ts`: `app.route("/api/community-prices", communityPriceRoutes)`
|
|
|
|
Create `tests/services/community-price.service.test.ts`:
|
|
- Test validateOwnership returns false for non-owner
|
|
- Test validateOwnership returns true when user owns item with that globalItemId
|
|
- Test submitCommunityPrice creates a price submission
|
|
- Test submitCommunityPrice returns null when user doesn't own item
|
|
- Test getCommunityPriceStats returns empty when < 3 reports
|
|
- Use createTestDb() helper
|
|
</action>
|
|
<acceptance_criteria>
|
|
- src/server/services/community-price.service.ts exports validateOwnership, submitCommunityPrice, getCommunityPriceStats
|
|
- src/server/routes/community-prices.ts has GET and POST handlers
|
|
- src/server/index.ts contains `app.route("/api/community-prices"`
|
|
- getCommunityPriceStats uses PERCENTILE_CONT for median
|
|
- getCommunityPriceStats HAVING COUNT(*) >= 3
|
|
- submitCommunityPrice checks ownership before insert
|
|
- `bun test tests/services/community-price.service.test.ts` passes
|
|
</acceptance_criteria>
|
|
<verify>
|
|
<automated>cd /home/jlmak/Projects/jlmak/GearBox && bun test tests/services/community-price.service.test.ts</automated>
|
|
</verify>
|
|
<done>Community price system working with ownership validation, median aggregation with 3-report minimum</done>
|
|
</task>
|
|
|
|
<task type="auto">
|
|
<name>Task 2: Update setup and totals services for currency awareness</name>
|
|
<files>src/server/services/setup.service.ts, src/server/services/totals.service.ts</files>
|
|
<read_first>src/server/services/setup.service.ts, src/server/services/totals.service.ts</read_first>
|
|
<action>
|
|
Note: The current SQL aggregates (SUM of price_cents) assume all prices are in the same currency. For now, this assumption holds because:
|
|
1. All existing data uses the same implicit currency
|
|
2. The user's personal items have `priceCurrency` defaulting to 'EUR'
|
|
3. Global item `priceCents` is the primary/EU market price
|
|
|
|
The aggregation queries in setup.service.ts and totals.service.ts should include the `priceCurrency` field in their response so the client can display the correct currency symbol, but the actual SUM logic does not need conversion yet (that would require the server to know the user's preferred currency during aggregation, which is a Plan 05/06 concern).
|
|
|
|
Update `src/server/services/setup.service.ts`:
|
|
- In `getSetupWithItems`: Add `priceCurrency: items.priceCurrency` to the itemList SELECT columns
|
|
- The `totalCost` aggregate stays as-is (all in primary currency for now)
|
|
|
|
Update `src/server/services/totals.service.ts`:
|
|
- No changes needed — totals are global aggregates returned to the authenticated user
|
|
- The client formatter (Plan 05) will handle displaying in the user's preferred currency
|
|
|
|
This is intentionally minimal — the server returns raw data with currency metadata, and the client handles conversion display. Server-side conversion for aggregates would require passing the user's currency preference through every query, which adds complexity without benefit when the primary market is EUR.
|
|
</action>
|
|
<acceptance_criteria>
|
|
- src/server/services/setup.service.ts getSetupWithItems includes priceCurrency in itemList select
|
|
- Existing `bun test` passes — no regressions in setup or totals tests
|
|
</acceptance_criteria>
|
|
<verify>
|
|
<automated>cd /home/jlmak/Projects/jlmak/GearBox && bun test</automated>
|
|
</verify>
|
|
<done>Setup and totals services return currency metadata alongside prices</done>
|
|
</task>
|
|
|
|
</tasks>
|
|
|
|
<threat_model>
|
|
## Trust Boundaries
|
|
|
|
| Boundary | Description |
|
|
|----------|-------------|
|
|
| client→server | Community price submissions — untrusted user price data |
|
|
| server→database | Ownership validation query |
|
|
|
|
## STRIDE Threat Register
|
|
|
|
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
|
|-----------|----------|-----------|-------------|-----------------|
|
|
| T-33-09 | Spoofing | POST /api/community-prices | mitigate | Auth middleware + ownership validation ensures only item owners can submit prices |
|
|
| T-33-10 | Tampering | community-price.service.ts | mitigate | Zod validation on all input fields, priceCents must be non-negative integer, currency max 3 chars |
|
|
| T-33-11 | Repudiation | community_prices | accept | Price submissions tracked with userId and createdAt — sufficient audit trail for a single-user app |
|
|
| T-33-12 | Information Disclosure | GET /api/community-prices | accept | Community price stats are intentionally public (anonymous aggregates, no individual prices exposed) |
|
|
</threat_model>
|
|
|
|
<verification>
|
|
- `bun test` passes (all tests including new community price tests)
|
|
- Community price stats respect 3-report minimum
|
|
- Ownership validation prevents unauthorized submissions
|
|
</verification>
|
|
|
|
<success_criteria>
|
|
- Community price CRUD with ownership gate
|
|
- Aggregation with median and minimum report threshold
|
|
- Setup items include currency metadata
|
|
- All tests pass
|
|
</success_criteria>
|
|
|
|
<output>
|
|
After completion, create `.planning/phases/33-currency-system/33-04-SUMMARY.md`
|
|
</output>
|