Commit Graph
15 Commits
Author SHA1 Message Date
Kevin L'Hereec 0ce6a7f4a7 fix: clear price to null when a comparison finds no merchant offer
Build & Deploy / build (push) Failing after 3s
Build & Deploy / docker (push) Skipped
Several memory sticks (e.g. Crucial CT8G48C40U5, Patriot Signature Line,
Kingston FURY Beast) kept displaying their pc-builder.io catalog price
("€35.99", "€39.99"...) even after clicking "Compare prices" and getting
"No live price found, using catalog price" - an unconfirmed number was
being shown as if it were still valid, which is misleading given
pc-builder.io's catalog is known to go stale (see the earlier RTX 5070
€128 vs €66.89 case).

New clearPriceIfNoOffers(): when ScrapedPartsController#compare finds
zero offers across all 7 supported merchants, the part's price/priceSource
are reset to null, regardless of whether the price previously came from
the pc-builder.io catalog or an earlier verified merchant match - per
explicit product decision, an empty comparison always wins over keeping a
possibly-stale number around.

- ScrapedPartsController#compare now calls clearPriceIfNoOffers() in the
  empty-offers branch, and returns the resulting price in the JSON
  response so the frontend can mirror it exactly
- NewPartSearch.vue: comparePrices() now sets part.price from the
  response's `price` field (mirroring exactly what the backend just
  persisted) instead of only updating it on a cheaper find; removed the
  now-inaccurate "using catalog price €X" message since that price no
  longer exists once compared with no result
- 3 new tests for clearPriceIfNoOffers: clears an unverified
  pc-builder.io price, also clears a previously-verified merchant price
  when no longer confirmed, and is a no-op when there was no price to
  begin with

Verified end-to-end against the reported Crucial CT8G48C40U5 example:
price went from 35.99 (pc-builder.io) to null after a real comparison
found zero matching offers on any merchant.
2026-07-24 11:20:47 +02:00
Kevin L'Hereec a1f6ff419a feat: raise search results limit from 30 to 150
Build & Deploy / build (push) Failing after 3s
Build & Deploy / docker (push) Skipped
30 results was too low once pc-builder.io's full catalog is scraped
(e.g. 47 RTX 5070/5070 Ti variants alone), silently truncating results
a user would otherwise see. Also added a result count hint in
NewPartSearch.vue since the list is scrollable (max-h-96) rather than
paginated, so it wasn't obvious more results existed below the fold.
2026-07-23 16:26:30 +02:00
Kevin L'Hereec 6053290501 feat: paginate pc-builder.io scraping and always keep the best known price
Build & Deploy / build (push) Failing after 5s
Build & Deploy / docker (push) Skipped
The RTX 5070 case that prompted this: only 100 GPUs out of pc-builder.io's
actual 6222 were being scraped (its category pages only render the first
page server-side), so cheaper RTX 5070 variants existed on the site but
were invisible to our search. Investigating further also surfaced that
even with full pagination, most recent GPU listings (RTX 5070 included)
have no fixed Amazon price on pc-builder.io at all - just a generic
"search on Amazon" link - so pagination alone doesn't fully solve price
coverage either.

Pagination (part_scraper_service.ts):
- Reverse-engineered pc-builder.io's /products/data endpoint, which powers
  the client-side DataTables widget behind every category page. Requires
  a session cookie + CSRF token scraped from a fresh category page load,
  and an exact field shape (columns[].data must be the literal strings
  "Name"/"Add to Build", not numeric indices - a naive DataTables-shaped
  request 500s otherwise).
- CATEGORY_CONFIG now tracks each category's real pc-builder.io
  category_id and a per-category maxProducts depth, chosen by actual
  catalog size and price-sensitivity: GPU near-full coverage (6000),
  smaller/less price-volatile categories capped lower (300-800) to keep a
  full `scrape:parts` run (all categories) in a reasonable time.
- ensureFreshCategory (triggered inline by a user's search when the cache
  is stale) uses a much shallower QUICK_REFRESH_MAX_PRODUCTS=200 cap
  instead of the category's full depth, since it runs inside the
  request/response cycle of a search - full-depth coverage is only done
  by the standalone `scrape:parts` command.

Always keep the best known price:
- scraped_parts.price is now nullable (migration): parts with no fixed
  pc-builder.io offer are kept and searchable instead of being silently
  skipped, so at least the model is discoverable even before a price is
  resolved.
- New price_source / price_checked_at columns track where the currently
  stored price came from ("pc-builder.io" or a merchant name from
  merchant_price_service.ts).
- ScrapedPartsController#compare now persists the cheapest offer found
  (via the new updatePriceIfCheaper()) when it beats the currently stored
  price - so comparing prices benefits every future search/import for
  that part, not just the user who triggered it. Never regresses the
  price: a comparison finding a higher price than what's stored is
  ignored.
- scrapeComponentType's catalog refresh applies the same "keep whichever
  price is lower" rule per row, so a routine pc-builder.io re-scrape can
  never overwrite a better price a comparison had already found and
  persisted with a worse (or missing) one.
- Search results (GET /scraped-parts) are now ordered priced-cheapest-first,
  then parts with no known price last (`ORDER BY price IS NULL, price ASC`),
  so unpriced parts stay visible without pushing priced results down.
- NewPartSearch.vue displays the catalog price inline (or "No price yet"),
  and the existing "Compare prices" flow now works for those unpriced
  parts the same way it does for priced ones.

7 new/updated functional tests: nullable price is searchable and sorted
last, and 3 tests for updatePriceIfCheaper (cheaper offer replaces price,
pricier offer is ignored, first-ever price is accepted for a previously
unpriced part).
2026-07-23 16:12:26 +02:00
Kevin L'Hereec 62ec796e05 fix: repair broken CSP and compare live merchant prices for new parts
Build & Deploy / build (push) Failing after 4s
Build & Deploy / docker (push) Skipped
CSP fix:
- The previous shield.ts config used '@viteDevUrl'/'@viteHmrUrl' CSP
  keywords that don't exist in this version of @adonisjs/shield (only
  '@viteUrl' is registered by @adonisjs/vite, and it resolves to an empty
  string here since Vite assets are proxied through AdonisJS on the same
  origin). Both placeholders were sent to the browser literally, breaking
  Google Fonts, the Vite HMR websocket, and pc-builder.io/favicon images.
- Replaced with concrete directives: 'self' covers same-origin Vite
  assets, ws://localhost:{VITE_HMR_PORT|24678} is added only in dev for
  the HMR socket, and explicit allowlists for Google Fonts
  (fonts.googleapis.com/fonts.gstatic.com), pc-builder.io product images,
  and Google's favicon service (www.google.com) used by the merchant
  comparison UI

Real purchase links + live price comparison:
- part_scraper_service.ts: ScrapedPart.sourceUrl now stores the actual
  Amazon.fr purchase link scraped from pc-builder.io's category pages
  (https://www.amazon.fr/dp/{ASIN}?tag=... or a search fallback), instead
  of the pc-builder.io product page, which isn't a place anyone can buy
  anything from
- New merchant_price_service.ts: on-demand live price lookup across
  Amazon, LDLC, Grosbill and TopAchat. Of 6 requested merchants,
  Materiel.net (search requires JS/Turbo, no results via plain GET) and
  Cdiscount/PCComponentes (empty body / 403) were dropped as not scrapable
  without a headless browser or paid API
- Matching strategy: pc-builder.io's full product names (e.g. "AMD Ryzen 3
  3200G 3.6 GHz Quad-Core Processor") return zero results on every
  merchant's search engine, so we search on a short brand+series query
  and then pick the specific result whose label contains the extracted
  model token (e.g. "3200G"). No token match means no offer returned,
  rather than risk quoting the price of a different, unrelated product
- Fixed price parsing for LDLC/Grosbill, which render prices as nested
  price/sup elements for whole euros vs cents (and LDLC additionally
  wraps that in a duplicate outer .price div) - naive text concatenation
  was misreading "64,95" as "6495"
- New GET /scraped-parts/compare endpoint (authenticated), called on
  demand per search result rather than for every row, since it makes
  several outbound requests and can take a few seconds
- NewPartSearch.vue: "Compare prices" button per result shows the
  cheapest merchant with its favicon and links to the actual offer;
  importing a part now prefers the live cheapest price/link over
  pc-builder.io's potentially stale catalog price (confirmed stale in
  practice: pc-builder.io listed a Ryzen 3 3200G at 128 euros while
  Amazon sells it for 66.89 euros)
- 3 new functional tests for the compare endpoint (auth required, 404 on
  unknown part, 422 on invalid id)
2026-07-23 14:58:33 +02:00
Kevin L'Hereec 497ae797ca feat: add 24h cache freshness with auto-refresh on search
Build & Deploy / build (push) Failing after 5s
Build & Deploy / docker (push) Skipped
- part_scraper_service.ts: new CACHE_TTL_HOURS (24), isCategoryStale()
  checks the most recent scrapedAt for a given ComponentType's mapped
  category, ensureFreshCategory() re-scrapes it synchronously if missing
  or stale (errors are logged, not thrown, so search still falls back to
  whatever is already cached)
- ScrapedPartsController#index: when a typeId filter is provided, the
  corresponding category cache is refreshed eagerly if stale before
  querying results. Free-text searches without a typeId skip this to
  avoid re-scraping every mapped category on every keystroke -
  'node ace scrape:parts' still refreshes everything at once on demand
- 4 new tests covering staleness detection (missing cache, <24h, >24h)
  and that unmapped types never trigger a scrape/network call
2026-07-23 13:58:37 +02:00
Kevin L'Hereec 5854ff8ed1 feat: search and import new parts scraped from pc-builder.io
Build & Deploy / build (push) Failing after 4s
Build & Deploy / docker (push) Skipped
Idealo and PCPartPicker were evaluated and ruled out:
- idealo.fr search pages return 503 (Cloudflare)
- the internal Idealo GraphQL endpoint used by a public scraper
  (app.idealo.de/app-backend/api) is protected by Akamai Bot Manager and
  silently returns empty data even with mobile app headers
- PCPartPicker's product list/search relies on a session-bound CSRF token
  refreshed per page load, against a scraping-hostile ToS

pc-builder.io category pages are server-rendered, include name/price/image
for ~100 products per category, and have no bot protection, so the feature
is built on top of that source only.

- New scraped_parts cache table (type_id, name, price, image_url, source,
  source_url, source_ref unique per source) populated by a batch job
  rather than scraped live per search, since only ~100 first results per
  category are available without reverse-engineering pc-builder.io's
  DataTables/CSRF pagination flow
- part_scraper_service.ts: maps our ComponentType names to pc-builder.io
  category slugs (SSD -> storage, since pc-builder.io has no separate HDD
  category) and parses each category page with cheerio, skipping rows
  without a resolved price (generic 'search on Amazon' links)
- node ace scrape:parts: refreshes the cache for every mapped category
  (upsert on source+source_ref), runnable manually or later via cron
- GET /scraped-parts (authenticated): search the cache by name (LOWER()
  LIKE, avoids a MySQL utf8_bin/utf8mb4 collation mismatch from Lucid's
  whereLike) and optional typeId filter
- NewPartSearch.vue: debounced search widget wired into
  components/create.vue; clicking 'Import' pre-fills the existing
  component creation form (name, price, type, purchase date = today)
  instead of creating the Component directly, so the user still reviews
  and saves it like any manually entered part
- 3 functional tests covering search by name, filter by typeId, and
  unauthenticated access being redirected to login
- Initial scrape run: 989 parts across 10 categories
2026-07-23 12:10:36 +02:00
Kevin L'Hereec da71d33450 feat: add public read-only sharing for computers
Build & Deploy / build (push) Failing after 5s
Build & Deploy / docker (push) Skipped
Adds the ability for an owner to share a computer build via a public,
unauthenticated link, inspired by a feature request from the original
pcbuilder repo (no such feature actually existed there — implemented
from scratch here).

- New columns on computers: share_token (unique, nullable), share_enabled,
  share_show_sell_price, share_show_cost
- computer_share_service.ts: generates a non-guessable share token
  (crypto.randomBytes, base64url), enables/disables sharing, and supports
  rotating the token to invalidate previously distributed links
- ComputerShareController (authenticated, owner-scoped like the rest of
  the app): PUT /computers/:id/share to update settings, POST
  /computers/:id/share/rotate to rotate the token
- PublicComputerController + GET /share/:token (outside any auth/guest
  group, reachable anonymously): resolves only computers with
  share_enabled = true, 404 otherwise
- ComputerPublicTransformer: dedicated read-only projection that never
  leaks userId, and only includes sellPrice/cost when the owner opted in
  via share_show_sell_price / share_show_cost — everything else (title,
  scores, state, components, pictures) is always visible on a shared link
- ComputerShareModal.vue: owner-facing UI (toggle sharing, choose which
  prices to expose, copy the public link, revoke/rotate it) wired into
  computers/show.vue via a new 'Share' button
- share/show.vue: public page rendered with EmptyLayout (no authenticated
  header/sidebar)
- Boolean columns use a  cast since MySQL returns tinyint(1),
  not native booleans, through Lucid
- 6 new functional tests covering: enabling sharing, price visibility
  toggles, disabling sharing revokes the link, cross-user protection on
  the share settings endpoint (404), token rotation invalidating the old
  link, and anonymous access to a shared computer
2026-07-23 11:43:38 +02:00
Kevin L'Hereec 94f89be605 chore: harden config, strengthen frontend typing, fix UTF-8 mojibake
Security config (3.1):
- config/session.ts: cookie.secure now follows app.inProduction instead of
  being hardcoded to false
- config/shield.ts: enable CSP with baseline directives compatible with
  Vite dev server and Inertia (self + @viteDevUrl/@viteHmrUrl)

Frontend typing (3.2):
- Replace 'any' props with concrete types (Computer, Component, ComponentType,
  ComputerState) across computers/*.vue and components/*.vue, using the
  existing inertia/pages/types/UITypes.ts definitions
- Fix UITypes.ts: export ComponentType/ComputerState, switch from interface
  to type (interfaces are never assignable to Inertia's ComponentProps index
  signature), drop the stale componentTypeId/componentStateId fields that
  didn't match the actual Lucid schema (real field is typeId, no component
  state exists)
- Add an asPageProp() boundary cast in the two controllers: Lucid's
  serialize() returns a loosely typed ModelObject that isn't structurally
  assignable to the concrete page prop shapes (and the inertia/** tree is
  excluded from the backend tsconfig, so real types can't be imported
  directly across that boundary)

UTF-8 mojibake fixes (3.3):
- Repair broken accented characters (├®/├á/├┤ -> é/à/ô, Ôé¼ -> €) left over
  from a bad encoding during the GWT->Inertia migration, across
  ComponentEditor.vue, ComputerComponentsTable.vue, SimpleGallery.vue,
  TableComponent.vue, isAdminMode.ts and the custom directives

Misc cleanup (3.5):
- computer.ts: replace console.warn with the app logger (includes
  computerId) when .cost is accessed without preloading components
2026-07-23 11:10:37 +02:00
Kevin L'Hereec 3cc189c534 fix(security): close IDOR on computers/components update & destroy
- Scope update/destroy queries by userId (Computers & Components), matching
  the pattern already used in index/show/edit
- Add missing auth.authenticate() calls on destroy actions
- handleComponents now requires userId and scopes component lookups to the
  current user, preventing cross-user component takeover
- handleComponents rejects attaching more than one component of the same
  'unique' type to a computer (new InvalidComponentSelectionException, 422)
- handlePictures scopes picture lookups by computerId
- destroy(Computer) now detaches its components (computerId = null) instead
  of losing them to cascade delete, and removes the computer's uploads dir
- Switch store/update actions to request.validateUsing() for consistency
- Fix relative redirects to absolute paths (/computers, /computers/:id)
- Untrack .env.production and add it to .gitignore (secrets must not be
  committed); local file kept on disk
2026-07-23 10:53:13 +02:00
Kevin d98e08f144 fix: TS errors, serialize calls, unused imports, pre-commit hook 2026-07-07 20:49:52 +02:00
Kevin d1fa68fe5b first fixes 2026-06-29 23:15:45 +02:00
Kevin 99c80b1b2f first fixes 2026-06-29 22:59:06 +02:00
Kevin e252a95c08 first fixes 2026-06-29 22:54:53 +02:00
Kevin 9cb4909481 fix
Build Project / build (push) Successful in 31s
2026-06-28 11:37:12 +02:00
Kevin 648d0af538 Initial commit: PC Builder AdonisJS project 2026-06-28 10:41:51 +02:00