Compare commits

..
31 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 fa74791c92 fix: update displayed catalog price after comparing merchant prices
Build & Deploy / build (push) Failing after 5s
Build & Deploy / docker (push) Skipped
The catalog price shown next to a part (e.g. '€979.95') stayed stuck at
its initial search-result value even after 'Compare prices' found and
persisted a cheaper offer server-side (updatePriceIfCheaper already saves
it to the database, but the locally held search result was never
updated) — so the UI showed a stale, higher price right above a
correctly-labeled 'cheapest' offer, which was confusing (e.g. '€979.95'
next to 'Cdiscount: €741.51 (cheapest)').

comparePrices() now also updates part.price locally when the cheapest
offer found beats it (or no catalog price was known), mirroring what the
backend already persisted, so the display is consistent without needing
to re-run the search.
2026-07-23 17:01:08 +02:00
Kevin L'Hereec 54c5e37766 fix: distinguish GPU model suffixes (Ti/Super/XT) and seed RTX 5060 series
Build & Deploy / build (push) Failing after 3s
Build & Deploy / docker (push) Skipped
RTX 5060 investigation: confirmed via pc-builder.io's own server-side
search (search_query=RTX 5060 against the full 6222-product GPU catalog,
0 results) that it simply isn't in pc-builder.io's catalog yet - not a
scraping bug, the source data doesn't have it. However, while checking
whether the other merchants had it in stock, found a real matching bug:

labelMatchesModelToken bug: extractModelToken("...RTX 5060 Ti...") only
returned "5060", identical to extractModelToken("...RTX 5060...") without
Ti. Every merchant lookup for either card matched on plain "5060" and
could silently return the *other* card's price (confirmed live: LDLC and
Grosbill returned the exact same product/price for both "RTX 5060" and
"RTX 5060 Ti" queries before this fix).

Fixed by:
- extractModelToken now appends a trailing suffix token (Ti, Super, XT,
  XTX) when it immediately follows the model number, so "5060 Ti" and
  "5060" are extracted as distinct tokens
- New labelMatchesModelToken() replaces the naive
  label.includes(modelToken) check everywhere: a bare token ("5060") no
  longer matches a label that actually has a suffix right after it
  ("5060 Ti"), while a suffixed token ("5060 Ti") only matches labels
  carrying that exact suffix
- 9 new unit tests covering extraction and matching, including the
  regression case (RTX 5060 must not match "RTX 5060 Ti" labels)

Verified fix live: LDLC/Grosbill now return correctly distinct prices for
"RTX 5060" (369.95/364.99) vs "RTX 5060 Ti" (489.95/329.99).

New manual_part_seeder_service.ts: since pc-builder.io's catalog lags
behind real product releases, this seeds a small manually-tracked list of
recent parts it doesn't carry yet (currently RTX 5060 8GB and 5060 Ti
16GB), resolving their price live from the already-supported merchants
rather than hardcoding a number that would go stale. `scrape:parts` now
runs this after the regular catalog scrape. Skips an entry rather than
inserting a guessed price if no merchant has it in stock.

Also fixes a real bug hit while testing the seeder: scraped_parts and
components.purchase_url were varchar(255), which truncates/rejects
Grosbill's long tracking-redirect URLs (500+ characters with encoded
analytics params) - both widened to TEXT via a new migration.
2026-07-23 16:51:29 +02:00
Kevin L'Hereec 21d56a5da4 fix: stop protecting stale, unverified pc-builder.io catalog prices
Build & Deploy / build (push) Failing after 3s
Build & Deploy / docker (push) Skipped
Bug found: "Gigabyte GAMING OC GeForce RTX 5070 Ti 16 GB Video Card" kept
showing a stale price of 431.17 even after pc-builder.io itself no longer
had a fixed offer for it (its "Add to Build" HTML only had a generic
Amazon search link, no .price-box element at all, confirmed by fetching
the live page directly).

Root cause: scrapeComponentType's "never lose a better price" rule (added
to make ScrapedPartsController#compare's persisted merchant prices
durable across catalog refreshes) was too broad. It treated ANY existing
price as worth protecting whenever the incoming catalog price was null or
higher - including a price that had only ever come from a *previous*
pc-builder.io scrape itself (priceSource: null), with no independent
verification. Since pc-builder.io's own listings change constantly (a
GPU can lose its priced Amazon link if the referenced ASIN goes out of
stock), that logic effectively froze old pc-builder.io prices forever
even after the site removed them.

Fix: extracted the decision into shouldKeepExistingPrice(), which now
only protects a price when priceSource is a real merchant name (set by
updatePriceIfCheaper after a live comparison confirmed it on the
merchant's own page) - never when priceSource is null (an unverified,
previous pc-builder.io catalog price). A plain re-scrape can always
correct/clear an unverified price, matching whatever pc-builder.io
currently shows.

4 new unit tests for shouldKeepExistingPrice covering: unverified
pc-builder.io price gets overwritten (the regression case), verified
merchant price is protected from worse/missing catalog data, verified
merchant price is still replaced by a genuinely cheaper catalog price,
and no-existing-part returns false.

Also re-ran `node ace scrape:parts` to apply the fix to already-cached
data with stale unverified prices (affected ~1500 rows across the
catalog before the fix, mostly likely unaffected in practice but no
longer silently trusted going forward).
2026-07-23 16:34:48 +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 9702d24a01 fix: allow gstatic.com in CSP img-src for merchant favicons
Build & Deploy / build (push) Failing after 5s
Build & Deploy / docker (push) Skipped
The favicon service used to show a merchant's icon in the price
comparison UI (www.google.com/s2/favicons) redirects to Google's gstatic
CDN (t0-t3.gstatic.com), which wasn't allowlisted — CSP silently blocked
the redirected image request (reported by some browsers as an opaque
response block rather than a clear CSP violation), so the 'Compare
prices' cheapest-offer favicon never rendered.

Added https://*.gstatic.com to img-src.
2026-07-23 15:38:00 +02:00
Kevin L'Hereec 25d6222df0 fix: add worker-src to CSP to fix broken HMR connection
Build & Deploy / build (push) Failing after 3s
Build & Deploy / docker (push) Skipped
Without an explicit worker-src directive, browsers fall back to
script-src ('self' only) for Web Workers, which blocks the blob: worker
spawned internally by Vite's HMR client / Tuyau's client. This caused the
dev HMR websocket connection to drop intermittently ('server connection
lost. Polling for restart...'), leaving the Vue app in an inconsistent
state after a failed hot reload — components using the Link component
(internally named TuyauLink, from @tuyau/core) would throw 'You must
wrap your app in a TuyauProvider' because the provide/inject context set
up at the app root was lost.

Added worker-src 'self' blob: to config/shield.ts. Verified: dev server
restarts cleanly with the HMR socket connecting without CSP violations,
full test suite (30/30) still passes.
2026-07-23 15:30:01 +02:00
Kevin L'Hereec a7788e593e feat: add Materiel.net, Cdiscount and PCComponentes via headless browser
Build & Deploy / build (push) Failing after 4s
Build & Deploy / docker (push) Skipped
Google/Bing/DuckDuckGo/Qwant were tested as a way to find prices on the
3 remaining requested merchants and all four block scraping immediately
(visible captcha or JS-only anti-bot challenge on the very first
automated request) - not usable as an intermediary. Instead, a headless
browser was used to scrape these 3 merchants directly, since their
blocking (JS-only search, empty body, 403) turned out to be bypassable
with real browser rendering rather than needing to get past a captcha:

- Materiel.net: search is entirely client-side (Stimulus/Turbo), a plain
  GET on /recherche/?query=... always returns the empty "no results"
  template. Fixed by actually filling the search input and clicking
  submit in a real page.
- Cdiscount: returns a fully empty response body to plain HTTP fetches
  (no error, just nothing) but renders fine in a real browser.
- PCComponentes: blocks plain HTTP requests with a 403, but a headless
  browser with a normal user agent goes through.

Implementation:
- New playwright-core dependency, pointed at a system-installed
  Chromium binary via CHROMIUM_EXECUTABLE_PATH (set to /usr/bin/chromium
  here) instead of downloading Playwright's own ~300MB browser
- New headless_browser_service.ts: lazily launches a single shared
  Chromium instance reused across lookups (avoids paying browser launch
  cost per request), with a withPage() helper that always closes its
  browser context afterwards. A new start/browser.ts preload registers
  an app.terminating() hook so the browser process is closed on shutdown
  instead of leaking a child process.
- fetchMaterielNetPrice/fetchCdiscountPrice/fetchPcComponentesPrice
  follow the same model-token matching strategy as the existing
  cheerio-based merchants (LDLC/Grosbill/TopAchat): search on a short
  brand+series query, then pick the result whose label/attribute
  contains the extracted model number, to avoid misreporting an
  unrelated product's price
- PCComponentes exposes clean data-product-price/data-product-category
  attributes directly on result links, so full pre-built "Sobremesa" PCs
  that happen to mention the searched CPU are excluded rather than
  reported as its price
- Fixed two bugs found while testing with a Ryzen 5 9600X (a case with
  multiple search results, unlike the Ryzen 3 3200G single-redirect case
  used earlier):
  - Materiel.net: a search results list page also has .o-product__price
    elements (one per card), so checking for their mere presence isn't
    enough to detect the "redirected to a single product" case; now
    disambiguated by URL pattern (/produit/ vs /recherche/)
  - New parseFlattenedFragmentedPriceText(): Playwright's innerText()
    flattens "<whole>€<sup>cents</sup>" prices to plain strings like
    "284€95" or "2 049€95", which the existing parser misread as
    28495/204995. Cheerio-based scrapers (LDLC/Grosbill) already handled
    this correctly via DOM structure (parseFragmentedPrice); this adds
    the equivalent for already-flattened text.
- findCheapestOffers() now queries all 7 merchants in parallel
- Extended the MerchantOffer type/favicon domain map on the frontend for
  the 3 new merchants

All 30 existing tests still pass unchanged (they don't exercise live
network scraping, only the surrounding auth/validation/caching logic).
2026-07-23 15:25:12 +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 4174d92711 feat: track purchase site URL for new components
Build & Deploy / build (push) Failing after 4s
Build & Deploy / docker (push) Skipped
- New nullable purchase_url column on components
- Store/update validators accept an optional purchaseUrl (must be a valid
  absolute URL with a protocol when provided)
- components/create.vue: NewPartSearch import now also fills purchaseUrl
  from the scraped part's source URL (pc-builder.io product page); the
  field remains manually editable for second-hand parts entered by hand
- components/edit.vue: purchaseUrl is editable after creation
- components/show.vue: displays the purchase site as a clickable link
  when present
- 4 new functional tests: create with/without a purchase URL, invalid URL
  rejected (asserts the Inertia inputErrorsBag flash, not a raw 422 -
  content negotiation only returns JSON for XHR/API-style requests), and
  updating an existing component's purchase URL
2026-07-23 14:10:54 +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 84ae2b9617 docs: mark remediation plan as executed with implementation summary
Build & Deploy / build (push) Failing after 4s
Build & Deploy / docker (push) Skipped
2026-07-23 11:30:20 +02:00
Kevin L'Hereec b88871879c docs(ci): flag NAS deploy secrets risk and .env.production breakage
Build & Deploy / build (push) Failing after 4s
Build & Deploy / docker (push) Skipped
Untracking .env.production (commit 3cc189c) breaks the 'cat .env.production'
step in this pipeline, since the job clones a fresh copy of the repo. This
is intentional: the file contained plaintext secrets and should never have
been committed. Documented inline:
- the deploy step will fail until secrets are migrated to Gitea Actions
  secrets and the .env file is generated at deploy time
- the sshpass/sudo -S pattern for NASPASS should be replaced with a
  dedicated SSH key and scoped passwordless sudo

Not changing the workflow itself: no access to the NAS to reconfigure
SSH/sudo or verify a fix end-to-end from this environment.
2026-07-23 11:28:01 +02:00
Kevin L'Hereec 562cea39de test: add functional regression tests for IDOR fixes (Phase 4)
- Add @japa/api-client devDependency (was missing despite being required
  for the 'client' fixture used in functional tests)
- Wire up sessionApiClient/authApiClient/shieldApiClient plugins in
  tests/bootstrap.ts so tests can authenticate (loginAs) and pass CSRF
  validation (withCsrfToken)
- Wrap each functional test in a global DB transaction (withGlobalTransaction)
  for isolation and rollback
- Add .env.test with SESSION_DRIVER=memory: the cookie session driver used
  in dev/prod is incompatible with Japa's session test client, which needs
  a store it can read/write by session id
- tests/functional/computers.spec.ts: covers owner can update; a user
  cannot update/delete another user's computer (404); a user cannot
  attach another user's component via componentsId (404); two components
  of the same unique type cannot be attached (422); deleting a computer
  detaches its components instead of destroying them
- tests/functional/components.spec.ts: covers owner can update; a user
  cannot update/delete another user's component (404); an unauthenticated
  request cannot delete a component (redirects to /auth/login)

All 10 tests pass, exercising the security fixes from commit 3cc189c.
2026-07-23 11:27:18 +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 3579c9dfec refactor: uniform validation, fix create form UX, dedupe filter dropdowns
- Remove dead registerValidator (Auth/register.ts), unused since signupValidator
  is the one actually wired to NewAccountController
- computers/create.vue: switch to useForm() + @submit.prevent so the form
  submits on Enter and shows a processing state, matching components/create.vue
- components/edit.vue: drop the 'origin' field, which was never provided by
  the backend (edit() never shared it) and therefore always undefined
- Extract FilterDropdown.vue and reuse it in ComponentsViewer/ComputersViewer,
  removing ~80 lines of duplicated dropdown markup/logic and fixing duplicate
  HTML ids (id="used" appeared twice in ComputersViewer)
- Move MIGRATION_*.md artifacts into docs/migration-history/
2026-07-23 11:10:17 +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 L'Hereec ec6734f491 chore: clean up dead UI code and regenerate schema
- Remove non-functional search bar, notifications and settings buttons from header
- Remove leftover console.log and unused useAttrs import in sidebar
- Remove unused scoped CSS in computers/index.vue (Tailwind-only template)
- Regenerate database/schema.ts after running migrations locally
- Ignore .serena/ local tooling directory
- Refresh package-lock.json after npm install
2026-07-23 10:21:51 +02:00
Kevin b3c8d691b4 fix: add migration step in CI deploy 2026-07-07 20:58:15 +02:00
Kevin d98e08f144 fix: TS errors, serialize calls, unused imports, pre-commit hook 2026-07-07 20:49:52 +02:00
Kevin f3cbbe901a fix: cookies HTTP, logout route prefix, header logout method 2026-07-07 20:35:41 +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 5b27625ead first fixes 2026-06-29 22:48:59 +02:00
Kevin 12d715f97b try to deploy en nas 2026-06-28 13:36:44 +02:00
Kevin d51ae26c70 try to deploy en nas 2026-06-28 13:28:14 +02:00
Kevin e8df8cee94 try to deploy en nas 2026-06-28 13:26:22 +02:00
81 changed files with 4712 additions and 992 deletions

No files matched your search

+5
View File
@@ -6,10 +6,15 @@
/// <reference path="./manifest.d.ts" />
import type { InferData, InferVariants } from '@adonisjs/core/types/transformers'
import type { InferSharedProps } from '@adonisjs/inertia/types'
import type ComputerPublicTransformer from '#transformers/computer_public_transformer'
import type UserTransformer from '#transformers/user_transformer'
import type InertiaMiddleware from '#middleware/inertia_middleware'
export namespace Data {
export type ComputerPublic = InferData<ComputerPublicTransformer>
export namespace ComputerPublic {
export type Variants = InferVariants<ComputerPublicTransformer>
}
export type User = InferData<UserTransformer>
export namespace User {
export type Variants = InferVariants<UserTransformer>
+30
View File
@@ -84,6 +84,24 @@ const routes = {
tokens: [{"old":"/computers/:id","type":0,"val":"computers","end":""},{"old":"/computers/:id","type":1,"val":"id","end":""}],
types: placeholder as Registry['computers.destroy']['types'],
},
'computers.share.update': {
methods: ["PUT"],
pattern: '/computers/:id/share',
tokens: [{"old":"/computers/:id/share","type":0,"val":"computers","end":""},{"old":"/computers/:id/share","type":1,"val":"id","end":""},{"old":"/computers/:id/share","type":0,"val":"share","end":""}],
types: placeholder as Registry['computers.share.update']['types'],
},
'computers.share.rotate': {
methods: ["POST"],
pattern: '/computers/:id/share/rotate',
tokens: [{"old":"/computers/:id/share/rotate","type":0,"val":"computers","end":""},{"old":"/computers/:id/share/rotate","type":1,"val":"id","end":""},{"old":"/computers/:id/share/rotate","type":0,"val":"share","end":""},{"old":"/computers/:id/share/rotate","type":0,"val":"rotate","end":""}],
types: placeholder as Registry['computers.share.rotate']['types'],
},
'share.show': {
methods: ["GET","HEAD"],
pattern: '/share/:token',
tokens: [{"old":"/share/:token","type":0,"val":"share","end":""},{"old":"/share/:token","type":1,"val":"token","end":""}],
types: placeholder as Registry['share.show']['types'],
},
'components.index': {
methods: ["GET","HEAD"],
pattern: '/components',
@@ -126,6 +144,18 @@ const routes = {
tokens: [{"old":"/components/:id","type":0,"val":"components","end":""},{"old":"/components/:id","type":1,"val":"id","end":""}],
types: placeholder as Registry['components.destroy']['types'],
},
'scraped_parts.index': {
methods: ["GET","HEAD"],
pattern: '/scraped-parts',
tokens: [{"old":"/scraped-parts","type":0,"val":"scraped-parts","end":""}],
types: placeholder as Registry['scraped_parts.index']['types'],
},
'scraped_parts.compare': {
methods: ["GET","HEAD"],
pattern: '/scraped-parts/compare',
tokens: [{"old":"/scraped-parts/compare","type":0,"val":"scraped-parts","end":""},{"old":"/scraped-parts/compare","type":0,"val":"compare","end":""}],
types: placeholder as Registry['scraped_parts.compare']['types'],
},
} as const satisfies Record<string, AdonisEndpoint>
export { routes }
+60
View File
@@ -163,6 +163,42 @@ export interface Registry {
errorResponse: ExtractErrorResponse<Awaited<ReturnType<import('#controllers/computers_controller').default['destroy']>>>
}
}
'computers.share.update': {
methods: ["PUT"]
pattern: '/computers/:id/share'
types: {
body: ExtractBody<InferInput<(typeof import('#validators/Computers/share').updateShareSettingsValidator)>>
paramsTuple: [ParamValue]
params: { id: ParamValue }
query: ExtractQuery<InferInput<(typeof import('#validators/Computers/share').updateShareSettingsValidator)>>
response: ExtractResponse<Awaited<ReturnType<import('#controllers/computer_share_controller').default['update']>>>
errorResponse: ExtractErrorResponse<Awaited<ReturnType<import('#controllers/computer_share_controller').default['update']>>> | { status: 422; response: { errors: SimpleError[] } }
}
}
'computers.share.rotate': {
methods: ["POST"]
pattern: '/computers/:id/share/rotate'
types: {
body: {}
paramsTuple: [ParamValue]
params: { id: ParamValue }
query: {}
response: ExtractResponse<Awaited<ReturnType<import('#controllers/computer_share_controller').default['rotate']>>>
errorResponse: ExtractErrorResponse<Awaited<ReturnType<import('#controllers/computer_share_controller').default['rotate']>>>
}
}
'share.show': {
methods: ["GET","HEAD"]
pattern: '/share/:token'
types: {
body: {}
paramsTuple: [ParamValue]
params: { token: ParamValue }
query: {}
response: ExtractResponse<Awaited<ReturnType<import('#controllers/public_computer_controller').default['show']>>>
errorResponse: ExtractErrorResponse<Awaited<ReturnType<import('#controllers/public_computer_controller').default['show']>>>
}
}
'components.index': {
methods: ["GET","HEAD"]
pattern: '/components'
@@ -247,4 +283,28 @@ export interface Registry {
errorResponse: ExtractErrorResponse<Awaited<ReturnType<import('#controllers/components_controller').default['destroy']>>>
}
}
'scraped_parts.index': {
methods: ["GET","HEAD"]
pattern: '/scraped-parts'
types: {
body: {}
paramsTuple: []
params: {}
query: {}
response: ExtractResponse<Awaited<ReturnType<import('#controllers/scraped_parts_controller').default['index']>>>
errorResponse: ExtractErrorResponse<Awaited<ReturnType<import('#controllers/scraped_parts_controller').default['index']>>>
}
}
'scraped_parts.compare': {
methods: ["GET","HEAD"]
pattern: '/scraped-parts/compare'
types: {
body: {}
paramsTuple: []
params: {}
query: {}
response: ExtractResponse<Awaited<ReturnType<import('#controllers/scraped_parts_controller').default['compare']>>>
errorResponse: ExtractErrorResponse<Awaited<ReturnType<import('#controllers/scraped_parts_controller').default['compare']>>>
}
}
}
+11
View File
@@ -20,6 +20,13 @@ export interface ApiDefinition {
edit: typeof routes['computers.edit']
update: typeof routes['computers.update']
destroy: typeof routes['computers.destroy']
share: {
update: typeof routes['computers.share.update']
rotate: typeof routes['computers.share.rotate']
}
}
share: {
show: typeof routes['share.show']
}
components: {
index: typeof routes['components.index']
@@ -30,4 +37,8 @@ export interface ApiDefinition {
update: typeof routes['components.update']
destroy: typeof routes['components.destroy']
}
scrapedParts: {
index: typeof routes['scraped_parts.index']
compare: typeof routes['scraped_parts.compare']
}
}
+3
View File
@@ -5,7 +5,10 @@
export const controllers = {
Components: () => import('#controllers/components_controller'),
ComputerShare: () => import('#controllers/computer_share_controller'),
Computers: () => import('#controllers/computers_controller'),
NewAccount: () => import('#controllers/new_account_controller'),
PublicComputer: () => import('#controllers/public_computer_controller'),
ScrapedParts: () => import('#controllers/scraped_parts_controller'),
Session: () => import('#controllers/session_controller'),
}
+4
View File
@@ -26,14 +26,18 @@ declare module '@adonisjs/inertia/types' {
'layout/emptyLayout': ExtractProps<(typeof import('../../inertia/pages/layout/emptyLayout.vue'))['default']>
'layout/header': ExtractProps<(typeof import('../../inertia/pages/layout/header.vue'))['default']>
'layout/sidebar': ExtractProps<(typeof import('../../inertia/pages/layout/sidebar.vue'))['default']>
'share/show': ExtractProps<(typeof import('../../inertia/pages/share/show.vue'))['default']>
'widgets/AuthErrorAlert': ExtractProps<(typeof import('../../inertia/pages/widgets/AuthErrorAlert.vue'))['default']>
'widgets/ComponentEditor': ExtractProps<(typeof import('../../inertia/pages/widgets/ComponentEditor.vue'))['default']>
'widgets/ComponentsViewer': ExtractProps<(typeof import('../../inertia/pages/widgets/ComponentsViewer.vue'))['default']>
'widgets/ComputerCard': ExtractProps<(typeof import('../../inertia/pages/widgets/ComputerCard.vue'))['default']>
'widgets/ComputerComponentsTable': ExtractProps<(typeof import('../../inertia/pages/widgets/ComputerComponentsTable.vue'))['default']>
'widgets/ComputerShareModal': ExtractProps<(typeof import('../../inertia/pages/widgets/ComputerShareModal.vue'))['default']>
'widgets/ComputersViewer': ExtractProps<(typeof import('../../inertia/pages/widgets/ComputersViewer.vue'))['default']>
'widgets/CustomSelect': ExtractProps<(typeof import('../../inertia/pages/widgets/CustomSelect.vue'))['default']>
'widgets/DeleteConfirmationModal': ExtractProps<(typeof import('../../inertia/pages/widgets/DeleteConfirmationModal.vue'))['default']>
'widgets/FilterDropdown': ExtractProps<(typeof import('../../inertia/pages/widgets/FilterDropdown.vue'))['default']>
'widgets/NewPartSearch': ExtractProps<(typeof import('../../inertia/pages/widgets/NewPartSearch.vue'))['default']>
'widgets/SimpleGallery': ExtractProps<(typeof import('../../inertia/pages/widgets/SimpleGallery.vue'))['default']>
'widgets/TableComponent': ExtractProps<(typeof import('../../inertia/pages/widgets/TableComponent.vue'))['default']>
'widgets/ValidationError': ExtractProps<(typeof import('../../inertia/pages/widgets/ValidationError.vue'))['default']>
+13
View File
@@ -17,6 +17,9 @@ export type ScannedRoutes = {
'computers.edit': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'computers.update': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'computers.destroy': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'computers.share.update': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'computers.share.rotate': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'share.show': { paramsTuple: [ParamValue]; params: {'token': ParamValue} }
'components.index': { paramsTuple?: []; params?: {} }
'components.create': { paramsTuple?: []; params?: {} }
'components.store': { paramsTuple?: []; params?: {} }
@@ -24,6 +27,8 @@ export type ScannedRoutes = {
'components.edit': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'components.update': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'components.destroy': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'scraped_parts.index': { paramsTuple?: []; params?: {} }
'scraped_parts.compare': { paramsTuple?: []; params?: {} }
}
GET: {
'home': { paramsTuple?: []; params?: {} }
@@ -33,10 +38,13 @@ export type ScannedRoutes = {
'computers.create': { paramsTuple?: []; params?: {} }
'computers.show': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'computers.edit': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'share.show': { paramsTuple: [ParamValue]; params: {'token': ParamValue} }
'components.index': { paramsTuple?: []; params?: {} }
'components.create': { paramsTuple?: []; params?: {} }
'components.show': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'components.edit': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'scraped_parts.index': { paramsTuple?: []; params?: {} }
'scraped_parts.compare': { paramsTuple?: []; params?: {} }
}
HEAD: {
'home': { paramsTuple?: []; params?: {} }
@@ -46,20 +54,25 @@ export type ScannedRoutes = {
'computers.create': { paramsTuple?: []; params?: {} }
'computers.show': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'computers.edit': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'share.show': { paramsTuple: [ParamValue]; params: {'token': ParamValue} }
'components.index': { paramsTuple?: []; params?: {} }
'components.create': { paramsTuple?: []; params?: {} }
'components.show': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'components.edit': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'scraped_parts.index': { paramsTuple?: []; params?: {} }
'scraped_parts.compare': { paramsTuple?: []; params?: {} }
}
POST: {
'new_account.store': { paramsTuple?: []; params?: {} }
'session.store': { paramsTuple?: []; params?: {} }
'session.destroy': { paramsTuple?: []; params?: {} }
'computers.store': { paramsTuple?: []; params?: {} }
'computers.share.rotate': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'components.store': { paramsTuple?: []; params?: {} }
}
PUT: {
'computers.update': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'computers.share.update': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'components.update': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
}
DELETE: {
+7 -1
View File
@@ -18,4 +18,10 @@ DB_HOST=127.0.0.1
DB_PORT=3306
DB_USER=admin
DB_PASSWORD=password
DB_DATABASE=pc_builderv2
DB_DATABASE=pc_builderv2
# Merchant price comparison (headless browser scraping via playwright-core)
# Path to a system Chromium/Chrome binary. Leave unset to let Playwright
# try its own bundled browser (must be installed separately with
# `npx playwright install chromium` if so).
CHROMIUM_EXECUTABLE_PATH=/usr/bin/chromium
-5
View File
@@ -1,5 +0,0 @@
APP_KEY=Z_CbQqpIxo4bGJ0WpqZkB3HoaLXvHf0J
APP_URL=http://192.168.1.39:3333
DB_PASSWORD=pcbuilder_secure_pass
DB_DATABASE=pc_builder
DB_USER=pcbuilder
+29
View File
@@ -41,6 +41,35 @@ jobs:
docker push ${IMAGE}:latest
docker push ${IMAGE}:${{ github.sha }}
# ---------------------------------------------------------------------
# ⚠️ SECURITY REVIEW NOTES (see pc_builder remediation plan, item 1.4)
# ---------------------------------------------------------------------
# This deploy step has two known issues that were flagged during a
# security review but intentionally NOT auto-fixed here (no access to
# reconfigure the NAS SSH/sudo setup from this environment):
#
# 1. `.env.production` was removed from git tracking (it contained
# plaintext APP_KEY/DB_PASSWORD and should never have been
# committed). The `cat .env.production` line below now reads a
# file that will NOT exist in a fresh `git clone` of this repo,
# since this job clones the repo from scratch at the top of the
# `docker` job. This step WILL fail until the deployment secrets
# are migrated to Gitea Actions secrets and the `.env` file is
# generated at deploy time instead of committed, e.g.:
# printf 'APP_KEY=%s\nDB_PASSWORD=%s\n...' \
# "${{ secrets.APP_KEY }}" "${{ secrets.DB_PASSWORD }}" | \
# sshpass ... "cat > /volume1/docker/pc_builder/.env"
# Until that migration happens, whoever runs this pipeline must
# place a non-committed `.env.production` file back in the
# repository checkout (or update this script) for deploys to work.
#
# 2. The NAS password (`secrets.NASPASS`) is passed on the command
# line via `sshpass -p` and `sudo -S`, which can leak through
# process listings and CI logs. Recommended remediation: switch to
# a dedicated SSH key stored as a Gitea secret, and configure a
# scoped passwordless `sudo` (NOPASSWD) on the NAS limited to the
# exact commands below (setup-db.sh, docker compose, docker exec),
# rather than transiting the interactive user's password.
- name: Deploy on NAS
run: |
apk add --no-cache openssh-client sshpass
+2
View File
@@ -9,6 +9,7 @@ dist
# Secrets
.env
.env.local
.env.production
.env.production.local
.env.development.local
.env.test
@@ -28,6 +29,7 @@ coverage
.fleet
.idea
.vscode
.serena
*.swp
*.swo
+363
View File
@@ -0,0 +1,363 @@
# Plan de remédiation — PC Builder
> **Statut : exécuté le 2026-07-23.** Voir le résumé d'implémentation à la
> fin de ce document pour le détail des commits et des décisions prises.
Basé sur la revue de code du 2026-07-23. Objectif : corriger les failles de
sécurité critiques avant toute mise en production, puis assainir la base du
code (majeur), puis traiter les points mineurs/qualité.
---
## Phase 1 — CRITIQUE (bloquant avant prod)
### 1.1 IDOR sur `update`/`destroy` (Computers & Components)
**Problème** : `computers_controller.ts` (`update` L92-103, `destroy` L105-110)
et `components_controller.ts` (`destroy` L77-82) chargent la ressource via
`findOrFail(id)` sans filtrer par `userId`. `destroy` des deux controllers
n'authentifie même pas l'utilisateur (`auth.authenticate()` absent). N'importe
quel utilisateur connecté peut modifier/supprimer les PC ou composants d'un
autre utilisateur.
**Remédiation** :
- `computers_controller.ts#update` : ajouter `auth.authenticate()`, remplacer
`Computer.findOrFail(computerId)` par
`Computer.query().where('user_id', user.id).andWhere('id', computerId).firstOrFail()`
(même pattern que `show`/`edit`).
- `computers_controller.ts#destroy` : ajouter `auth.authenticate()`, même
pattern de query scopée par `user_id`.
- `components_controller.ts#destroy` : ajouter `auth.authenticate()`, remplacer
`Component.findOrFail(componentId)` par une query scopée
`where('userId', user.id).andWhere('id', componentId).firstOrFail()`.
- Vérifier les routes (`start/routes.ts`) : `middleware.auth()` est déjà
appliqué sur les groupes `/computers` et `/components`, donc l'utilisateur
est authentifiable dans le contexte — juste manquant l'appel explicite +
le filtre.
**Fichiers impactés** :
- `app/controllers/computers_controller.ts`
- `app/controllers/components_controller.ts`
### 1.2 IDOR transitif via `handleComponents` / `handlePictures`
**Problème** : même après la 1.1, `component_service.ts#handleComponents`
(L11, L18) et `picture_service.ts#handlePictures` (L22, L47) font
`findOrFail(id)` sans vérifier que le composant/l'image appartient à
l'utilisateur courant. Un attaquant authentifié peut poster un `componentsId`
contenant l'ID d'un composant appartenant à un autre utilisateur pour
se l'approprier (`component.computerId = computer.id`).
**Remédiation** :
- `handleComponents(componentsId, computer, userId)` : ajouter le paramètre
`userId`, et scoper les requêtes `Component.query().where('userId', userId).andWhere('id', id).firstOrFail()`
au lieu de `Component.findOrFail(id)`. Si un ID demandé n'appartient pas à
l'utilisateur, lever une erreur explicite (404 ou 403) plutôt que d'échouer
silencieusement.
- `handlePictures` : scoper `ComputerPicture.findOrFail(picture.id)` par
`computerId` du computer courant (`.where('computerId', computer.id)`), pour
empêcher de manipuler des images d'un autre computer/utilisateur.
- Mettre à jour les appels dans `computers_controller.ts#store` et `#update`
pour passer `user.id`.
**Fichiers impactés** :
- `app/services/component_service.ts`
- `app/services/picture_service.ts`
- `app/controllers/computers_controller.ts` (appels à `handleComponents`)
### 1.3 Secrets commités : `.env.production`
**Problème** : `.env.production` est suivi par git et contient `APP_KEY`,
`DB_PASSWORD`, `DB_USER`, `DB_DATABASE` en clair (commit `c822872`). Le CI
(`.gitea/workflows/build.yml`) le lit et le déploie tel quel sur le NAS.
**Remédiation** (à faire avec le user — actions sensibles) :
1. **Révoquer/régénérer immédiatement** les secrets réels côté NAS :
nouveau `APP_KEY` (`node ace generate:key`), nouveau mot de passe MySQL,
éventuellement recréer l'utilisateur DB.
2. Ajouter `.env.production` au `.gitignore` (garder uniquement
`.env.production.example` comme gabarit, déjà présent et sans valeurs
réelles).
3. Retirer le fichier du suivi git : `git rm --cached .env.production`.
4. Purger l'historique git du secret (`git filter-repo` ou BFG Repo-Cleaner)
— **à valider avec l'utilisateur** car cela réécrit l'historique partagé
(impact sur les autres clones/branches).
5. Migrer la distribution des secrets de prod vers les **Secrets Gitea Actions**
(comme `secrets.NASPASS`) plutôt qu'un fichier committé : injecter les
variables via `${{ secrets.APP_KEY }}`, `${{ secrets.DB_PASSWORD }}`, etc.,
et générer `.env` à la volée dans le job de déploiement.
**Fichiers impactés** :
- `.gitignore`
- `.gitea/workflows/build.yml`
- `.env.production` (suppression du suivi)
- Historique git (opération destructive, confirmation utilisateur requise)
### 1.4 Secrets en ligne de commande dans le pipeline CI (NASPASS)
**Problème** : `.gitea/workflows/build.yml` L46-56 utilise `sshpass -p` et
`sudo -S` avec le secret NAS directement en argument de commande shell —
risque d'exposition dans les logs/process list.
**Remédiation** :
- Remplacer l'authentification par mot de passe par une **clé SSH dédiée**
stockée en secret Gitea (`secrets.NAS_SSH_KEY`), utilisée via
`ssh -i <(echo "$NAS_SSH_KEY")` ou `webfactory/ssh-agent`-like action.
- Configurer un accès `sudo` sans mot de passe restreint aux commandes
nécessaires (`visudo` avec `NOPASSWD` limité aux scripts `setup-db.sh`,
`docker compose`, `docker exec`) plutôt que de faire transiter le mot de
passe utilisateur via `sudo -S`.
- À défaut de refonte complète immédiate, au minimum masquer la commande
(`set +x` explicite, éviter `-x` en debug) et documenter le risque résiduel.
**Fichiers impactés** :
- `.gitea/workflows/build.yml`
- Configuration NAS (hors dépôt — à faire manuellement)
### 1.5 Pas de contrainte d'unicité applicative pour les composants "uniques"
**Problème** : `ComponentType.unique` (ex: un seul CPU par PC) n'est vérifié
nulle part côté serveur. Un POST direct sur `/computers` ou `/computers/:id`
avec plusieurs composants du même type "unique" dans `componentsId` est accepté.
**Remédiation** :
- Dans `handleComponents`, après résolution des composants à ajouter, grouper
par `typeId` (en préchargeant `type.unique`) et rejeter (erreur 422/exception
métier) si plus d'un composant du même type "unique" seraient attachés au
computer simultanément.
- Ajouter un test automatisé couvrant ce cas (voir Phase 4).
**Fichiers impactés** :
- `app/services/component_service.ts`
---
## Phase 2 — MAJEUR (avant la prochaine itération fonctionnelle)
### 2.1 Redirections relatives fragiles
**Problème** : `computers_controller.ts#store` (L46) fait
`response.redirect('computers/')` et `#update` (L102) fait
`response.redirect(`${computer.id}`)` — chemins relatifs incohérents avec
le reste du code (`/components`, `/auth/login`, etc.).
**Remédiation** : remplacer par des chemins absolus explicites :
- `store` → `response.redirect('/computers')`
- `update` → `response.redirect(`/computers/${computer.id}`)`
### 2.2 Incohérence `request.all()` vs `request.validateUsing()`
**Problème** : `components_controller.ts` et `computers_controller.ts`
utilisent `request.all()` + `xxxValidator.validate(data)` au lieu du helper
standard `request.validateUsing(validator)` utilisé par
`session_controller.ts`/`new_account_controller.ts`. Risque d'incohérence
sur le flash des erreurs de validation Inertia (`inputErrorsBag`).
**Remédiation** : uniformiser tous les controllers sur
`await request.validateUsing(xxxValidator)`, supprimer les appels manuels à
`.validate()` sur `request.all()`.
**Fichiers impactés** :
- `app/controllers/computers_controller.ts` (store, update)
- `app/controllers/components_controller.ts` (store, update)
### 2.3 Validateurs d'inscription dupliqués et incohérents
**Problème** : `app/validators/Auth/register.ts` (`registerValidator`) n'est
utilisé nulle part (code mort) ; `new_account_controller.ts` utilise
`signupValidator` de `app/validators/user.ts`. Les deux ont des règles
différentes sur `name` (obligatoire minLength 3 vs nullable sans contrainte).
**Remédiation** :
- Supprimer `app/validators/Auth/register.ts` (code mort), ou le fusionner
si une seconde route d'inscription est prévue.
- Décider de la règle métier définitive sur `name` (obligatoire ou non) dans
`signupValidator` et l'appliquer.
### 2.4 Formulaire `computers/create.vue` ne se soumet pas via Enter / pas d'état de chargement
**Problème** : le bouton "Create" est un `<Link method="post">` externe au
`<form>` (pas de `@submit.prevent`), contrairement à `components/create.vue`
qui utilise `useForm` + `@submit.prevent` correctement.
**Remédiation** : refactorer `computers/create.vue` pour utiliser
`useForm()` d'Inertia avec `@submit.prevent="form.post('/computers')"`,
afficher `form.processing` sur le bouton (état de chargement), harmoniser
avec le pattern de `components/create.vue`.
### 2.5 `origin` fantôme dans `components/edit.vue`
**Problème** : la page lit `props.origin` et le renvoie dans le payload,
mais `components_controller.ts#edit` ne le partage jamais dans
`inertia.render(...)`.
**Remédiation** : soit supprimer complètement `origin` du formulaire et du
type de props si la fonctionnalité est abandonnée, soit l'implémenter
proprement si un besoin réel existe (ex: retour à la page d'origine après
modification). À trancher avec l'utilisateur — proposition : suppression
(simplification), sauf avis contraire.
### 2.6 Suppression d'un `Computer` : cascade destructive sur les composants + fichiers orphelins
**Problème** : `destroy` d'un `Computer` supprime en cascade DB ses
`Component` liés au lieu de les détacher (`computerId = null`), ce qui fait
perdre définitivement les composants de l'utilisateur. De plus, les fichiers
images physiques (`uploads/computer_X/...`) ne sont jamais nettoyés lors
d'un `destroy` de computer (seulement lors d'un retrait explicite via update).
**Remédiation** :
- Dans `computers_controller.ts#destroy`, avant `computer.delete()` :
détacher les composants (`UPDATE components SET computer_id = NULL WHERE computer_id = ?`)
et supprimer physiquement le dossier `uploads/computer_${id}` (+ les lignes
`computer_pictures`, qui elles peuvent rester en cascade DB puisque les
fichiers sont supprimés en même temps).
- Vérifier/adapter la contrainte `onDelete('CASCADE')` sur `components.computer_id`
dans la migration si on souhaite un `SET NULL` réellement porté par la DB
plutôt qu'un traitement applicatif (nécessiterait une migration corrective).
**Fichiers impactés** :
- `app/controllers/computers_controller.ts`
- Éventuellement une nouvelle migration si `onDelete` doit changer.
### 2.7 Duplication `ComponentsViewer.vue` / `ComputersViewer.vue`
**Remédiation** : extraire un composant générique `FilterDropdown.vue`
(props : liste d'options `{id, label}`, `v-model` sur l'état de filtre,
slot ou prop pour le libellé du bouton) réutilisé dans les deux vues, pour
éliminer la duplication du markup dropdown + logique `toggleDropdown`.
### 2.8 Composants "widgets" orphelins et fonctionnalité galerie incomplète
**Problème** : `ComponentEditor.vue`, `ComputerComponentsTable.vue`,
`SimpleGallery.vue`, `AuthErrorAlert.vue` ne sont référencés par aucune page
active. La gestion de galerie photo (`SimpleGallery.vue` + `picture_service.ts`)
n'est appelée par aucun formulaire actuel (`computers/create.vue`/`edit.vue`
n'ont pas de champ pictures).
**Remédiation** — décision à prendre avec l'utilisateur :
- **Option A (simplification)** : supprimer ces composants orphelins et le
code backend de gestion d'images s'ils ne sont plus dans la roadmap.
- **Option B (finaliser la fonctionnalité)** : intégrer `SimpleGallery.vue`
dans `computers/create.vue`/`edit.vue` pour rendre l'upload d'images
réellement fonctionnel, et réintégrer `ComponentEditor.vue` (gestion
propre des composants uniques via select) à la place des checkboxes brutes
actuelles — ce qui réglerait aussi une partie du problème 1.5 côté UX.
---
## Phase 3 — MINEUR (qualité, hygiène)
### 3.1 Sécurité de configuration
- `config/session.ts` L45 : remplacer `secure: false` par `secure: app.inProduction`.
- `config/shield.ts` L12 : activer une CSP de base (`csp.enabled: true`)
avec des directives minimales (`default-src 'self'`, autoriser les sources
Vite en dev).
### 3.2 Typage frontend
- Remplacer les `any` dans `computers/*.vue` et `components/*.vue` par les
types réels (`Computer`, `Component`, `ComputerState`, `ComponentType`
importés depuis `#models/*` comme le fait déjà `ComponentsViewer.vue`).
- Corriger/aligner `inertia/pages/types/UITypes.ts` avec le schéma backend
réel (`typeId` au lieu de `componentTypeId`, retirer `componentStateId`
inexistant) ou supprimer ce fichier si les vrais modèles Lucid suffisent.
### 3.3 Encodage UTF-8 cassé (mojibake)
- Corriger l'encodage dans `ComponentsViewer.vue`, `ComponentEditor.vue`,
`ComputerComponentsTable.vue`, `TableComponent.vue` (caractères `├®`, `Ôé¼`
etc. à réécrire en UTF-8 propre : é, à, ô, €).
### 3.4 Accessibilité
- Corriger les doublons d'`id` HTML dans `ComputersViewer.vue` (L82/94,
deux fois `id="used"`).
- Ajouter `aria-expanded`/`aria-haspopup` sur les boutons de dropdown filtres.
- Remplacer `CustomSelect.vue` par un `<select>` natif stylé, ou ajouter les
rôles ARIA manquants (`role="listbox"`, navigation clavier).
### 3.5 Nettoyage divers
- Remplacer `console.warn` dans `computer.ts#cost` par un logger applicatif
ou un comportement documenté.
- Déplacer/supprimer `MIGRATION_PAGES_SUMMARY.md`, `MIGRATION_COMPLETE.md`,
`MIGRATION_CHECKLIST.md` (artefacts temporaires) vers `docs/` ou les
supprimer une fois la migration validée.
---
## Phase 4 — Tests de non-régression
Pour valider les correctifs de sécurité (Phase 1), ajouter des tests
fonctionnels Japa :
- Un utilisateur A ne peut pas `PUT`/`DELETE` un computer/component
appartenant à l'utilisateur B (attend 404/403).
- Un utilisateur ne peut pas attacher via `componentsId` un composant
appartenant à un autre utilisateur.
- Impossible d'attacher deux composants du même type "unique" à un même
computer.
- Après suppression d'un computer, ses composants redeviennent disponibles
(`computerId = null`) au lieu d'être supprimés.
**Fichiers** : `tests/functional/computers.spec.ts`,
`tests/functional/components.spec.ts` (à créer si absents).
---
## Ordre d'exécution proposé
1. **Phase 1** entièrement (sécurité bloquante), avec confirmation
utilisateur explicite avant toute réécriture d'historique git (1.3) ou
changement de config NAS (1.4).
2. **Phase 4** (tests) en parallèle de la Phase 1 pour prouver les correctifs.
3. **Phase 2** (majeur), avec un point de décision utilisateur sur 2.5 et 2.8
(garder/supprimer/finaliser des fonctionnalités).
4. **Phase 3** (mineur) en dernier, en petits commits séparés par thème
(config sécurité / typage / encodage / accessibilité / nettoyage fichiers).
## Points nécessitant une décision explicite de l'utilisateur avant exécution
- **1.3** : confirmer la réécriture de l'historique git pour purger le secret,
et confirmer que les secrets réels (APP_KEY, DB_PASSWORD) seront bien
régénérés côté NAS avant/juste après.
- **1.4** : confirmer l'accès pour reconfigurer l'authentification SSH/sudo
sur le NAS (hors du dépôt, nécessite un accès serveur).
- **2.5** : garder, implémenter ou supprimer le champ `origin`.
- **2.8** : supprimer les composants orphelins (Option A) ou finaliser la
fonctionnalité galerie/éditeur de composants uniques (Option B).
---
## Résumé d'implémentation (2026-07-23)
Toutes les phases du plan ont été exécutées. Commits (après réécriture
d'historique, voir 1.3 ci-dessous) :
- `3cc189c` — fix(security): close IDOR on computers/components update & destroy
(Phase 1: 1.1, 1.2, 1.5, 2.1, 2.2, retrait du suivi de `.env.production`)
- `3579c9d` — refactor: uniform validation, fix create form UX, dedupe filter
dropdowns (Phase 2: 2.3, 2.4, 2.5, 2.7, déplacement `MIGRATION_*.md`)
- `94f89be` — chore: harden config, strengthen frontend typing, fix UTF-8
mojibake (Phase 3: 3.1, 3.2, 3.3, 3.5)
- `562cea3` — test: add functional regression tests for IDOR fixes (Phase 4)
- `b888718` — docs(ci): flag NAS deploy secrets risk and .env.production
breakage (documentation de 1.4)
### Décisions prises avec l'utilisateur
- **1.3 (purge historique)** : `git-filter-repo` a été utilisé pour retirer
`.env.production` de tous les commits, une sauvegarde bundle complète a été
créée avant l'opération (`/tmp/user/1000/kilo/pc_builder_backup_*.bundle`),
puis l'historique réécrit a été poussé en force vers `origin/main`. Tout
autre clone existant du dépôt doit être resynchronisé
(`git fetch && git reset --hard origin/main`). **Les secrets réels
(APP_KEY, DB_PASSWORD du NAS) doivent encore être régénérés manuellement**,
car ils ont circulé en clair dans l'historique avant la purge.
- **1.4 (CI/NAS)** : documenté uniquement dans `.gitea/workflows/build.yml`
(pas d'accès au NAS pour reconfigurer SSH/sudo). Le retrait de
`.env.production` du suivi git casse le step `cat .env.production` du
pipeline de déploiement — c'est documenté en commentaire, à corriger en
migrant les secrets vers Gitea Actions.
- **2.5 (champ `origin`)** : supprimé (jamais fourni par le backend).
- **2.8 (widgets orphelins)** : conservés tels quels, non supprimés.
### Non traité
- Phase 2.6 a été traitée dans le commit de sécurité (détachement des
composants + nettoyage des fichiers uploads à la suppression d'un
ordinateur), avancée par rapport au planning initial.
- 2.8 tranché "garder tel quel" — aucune action requise.
+1
View File
@@ -73,6 +73,7 @@ export default defineConfig({
() => import('#start/routes'),
() => import('#start/kernel'),
() => import('#start/validator'),
() => import('#start/browser'),
],
/*
+31 -11
View File
@@ -5,22 +5,39 @@ import { updateComponentValidator } from '#validators/Components/update'
import Component from '#models/component'
import ComponentType from '#models/component_type'
/**
* Lucid's `serialize()` returns a loosely typed `ModelObject`
* (`{ [key: string]: any }`). It is not structurally assignable to the
* concrete prop shapes declared by the Vue pages (see
* `inertia/pages/types/UITypes.ts`), which live in a separate TypeScript
* project (the `inertia/**` tree is excluded from the backend tsconfig).
* Since the controller fully controls which columns/relations are
* preloaded and serialized, this cast documents that the runtime shape
* matches what the page expects at this boundary.
*/
function asPageProp<T>(value: unknown): T {
return value as T
}
export default class ComponentsController {
async index({ inertia, auth }: HttpContext) {
const user = await auth.authenticate()
const components = await Component.query().preload('type').where('userId', user.id)
return inertia.render('components/index', { components: components.map((c) => c.serialize()) })
return inertia.render('components/index', {
components: asPageProp(components.map((c) => c.serialize())),
})
}
async create({ inertia }: HttpContext) {
const types = await ComponentType.all()
return inertia.render('components/create', { types: types.map((t) => t.serialize()) })
return inertia.render('components/create', {
types: asPageProp(types.map((t) => t.serialize())),
})
}
async store({ request, response, auth }: HttpContext) {
const data = request.all()
const user = await auth.authenticate()
const { quantity, ...componentData } = await storeComponentValidator.validate(data)
const { quantity, ...componentData } = await request.validateUsing(storeComponentValidator)
await ComponentType.findOrFail(componentData.typeId)
for (let i = 0; i < quantity; i++) {
await Component.create({
@@ -40,7 +57,7 @@ export default class ComponentsController {
.where('userId', user.id)
.andWhere('id', componentId)
.firstOrFail()
return inertia.render('components/show', { component: component.serialize() })
return inertia.render('components/show', { component: asPageProp(component.serialize()) })
}
async edit({ params, auth, inertia }: HttpContext) {
@@ -53,15 +70,14 @@ export default class ComponentsController {
.andWhere('id', componentId)
.firstOrFail()
return inertia.render('components/edit', {
component: component.serialize(),
types: types.map((t) => t.serialize()),
component: asPageProp(component.serialize()),
types: asPageProp(types.map((t) => t.serialize())),
})
}
async update({ params, auth, request, response }: HttpContext) {
const data = request.all()
const componentData = await updateComponentValidator.validate(data)
const user = await auth.authenticate()
const componentData = await request.validateUsing(updateComponentValidator)
const component = await Component.query()
.where('userId', user.id)
.andWhere('id', params.id)
@@ -74,9 +90,13 @@ export default class ComponentsController {
return response.redirect('/components')
}
async destroy({ params, response }: HttpContext) {
async destroy({ params, auth, response }: HttpContext) {
const user = await auth.authenticate()
const componentId = params.id
const component = await Component.findOrFail(componentId)
const component = await Component.query()
.where('userId', user.id)
.andWhere('id', componentId)
.firstOrFail()
await component.delete()
return response.redirect('/components')
}
@@ -0,0 +1,46 @@
import type { HttpContext } from '@adonisjs/core/http'
import Computer from '#models/computer'
import { updateShareSettingsValidator } from '#validators/Computers/share'
import { disableSharing, enableSharing, rotateShareToken } from '#services/computer_share_service'
export default class ComputerShareController {
/**
* Update the share settings (enable/disable + which prices are visible)
* for a computer owned by the current user.
*/
async update({ auth, params, request, response }: HttpContext) {
const user = await auth.authenticate()
const computer = await Computer.query()
.where('user_id', user.id)
.andWhere('id', params.id)
.firstOrFail()
const { enabled, showSellPrice, showCost } = await request.validateUsing(
updateShareSettingsValidator
)
if (enabled) {
await enableSharing(computer, { showSellPrice, showCost })
} else {
await disableSharing(computer)
}
return response.redirect(`/computers/${computer.id}`)
}
/**
* Rotate the share token for a computer, invalidating any previously
* distributed public link.
*/
async rotate({ auth, params, response }: HttpContext) {
const user = await auth.authenticate()
const computer = await Computer.query()
.where('user_id', user.id)
.andWhere('id', params.id)
.firstOrFail()
await rotateShareToken(computer)
return response.redirect(`/computers/${computer.id}`)
}
}
+56 -16
View File
@@ -1,4 +1,5 @@
import type { HttpContext } from '@adonisjs/core/http'
import { existsSync, rmSync } from 'node:fs'
import Computer from '#models/computer'
import ComputerState from '#models/computer_state'
import Component from '#models/component'
@@ -8,6 +9,20 @@ import { handlePictures } from '#services/picture_service'
import type { Image } from '#types/UItypes'
import { updateComputerValidator } from '#validators/Computers/update'
/**
* Lucid's `serialize()` returns a loosely typed `ModelObject`
* (`{ [key: string]: any }`). It is not structurally assignable to the
* concrete prop shapes declared by the Vue pages (see
* `inertia/pages/types/UITypes.ts`), which live in a separate TypeScript
* project (the `inertia/**` tree is excluded from the backend tsconfig).
* Since the controller fully controls which columns/relations are
* preloaded and serialized, this cast documents that the runtime shape
* matches what the page expects at this boundary.
*/
function asPageProp<T>(value: unknown): T {
return value as T
}
export default class ComputersController {
async index({ auth, inertia }: HttpContext) {
const user = await auth.authenticate()
@@ -19,7 +34,9 @@ export default class ComputersController {
picturesQuery.where('order', 1)
})
return inertia.render('computers/index', { computers: computers.map((c) => c.serialize()) })
return inertia.render('computers/index', {
computers: asPageProp(computers.map((c) => c.serialize())),
})
}
async create({ auth, inertia }: HttpContext) {
@@ -29,21 +46,24 @@ export default class ComputersController {
.where('userId', user.id)
.whereNull('computerId')
.preload('type')
return inertia.render('computers/create', { components: components.map((c) => c.serialize()), states: states.map((s) => s.serialize()) })
return inertia.render('computers/create', {
components: asPageProp(components.map((c) => c.serialize())),
states: asPageProp(states.map((s) => s.serialize())),
})
}
async store({ auth, request, response }: HttpContext) {
const data = request.all()
const user = await auth.authenticate()
const { pictures, componentsId, ...computerData } = await storeComputerValidator.validate(data)
const { pictures, componentsId, ...computerData } =
await request.validateUsing(storeComputerValidator)
await ComputerState.findOrFail(computerData.stateId)
const computer = await Computer.create({
...computerData,
userId: user.id,
})
await handleComponents(componentsId as number[], computer)
await handleComponents(componentsId as number[], computer, user.id)
await handlePictures(pictures as Image[], request, computer)
return response.redirect('computers/')
return response.redirect('/computers')
}
async show({ auth, inertia, params }: HttpContext) {
@@ -60,7 +80,9 @@ export default class ComputersController {
.where('user_id', user.id)
.andWhere('id', computerId)
.firstOrFail()
return inertia.render('computers/show', { computer: computer.serialize() })
return inertia.render('computers/show', {
computer: asPageProp({ ...computer.serialize(), shareToken: computer.shareToken }),
})
}
async edit({ params, inertia, auth }: HttpContext) {
@@ -86,25 +108,43 @@ export default class ComputersController {
'id',
computer.components.map((c) => c.id)
)
return inertia.render('computers/edit', { computer: computer.serialize(), availableComponents: availableComponents.map((c) => c.serialize()), states: states.map((s) => s.serialize()) })
return inertia.render('computers/edit', {
computer: asPageProp(computer.serialize()),
availableComponents: asPageProp(availableComponents.map((c) => c.serialize())),
states: asPageProp(states.map((s) => s.serialize())),
})
}
async update({ params, request, response }: HttpContext) {
async update({ auth, params, request, response }: HttpContext) {
const user = await auth.authenticate()
const computerId = params.id
const { pictures, componentsId, ...computerData } = await updateComputerValidator.validate(
request.all()
const { pictures, componentsId, ...computerData } = await request.validateUsing(
updateComputerValidator
)
const computer = await Computer.findOrFail(computerId)
const computer = await Computer.query()
.where('user_id', user.id)
.andWhere('id', computerId)
.firstOrFail()
computer.merge(computerData)
await computer.save()
await handleComponents(componentsId as number[], computer)
await handleComponents(componentsId as number[], computer, user.id)
await handlePictures(pictures as Image[], request, computer)
return response.redirect(`${computer.id}`)
return response.redirect(`/computers/${computer.id}`)
}
async destroy({ params, response }: HttpContext) {
async destroy({ auth, params, response }: HttpContext) {
const user = await auth.authenticate()
const computerId = params.id
const computer = await Computer.findOrFail(computerId)
const computer = await Computer.query()
.where('user_id', user.id)
.andWhere('id', computerId)
.firstOrFail()
const uploadsDir = `uploads/computer_${computer.id}`
await computer.related('components').query().update({ computerId: null })
if (existsSync(uploadsDir)) {
rmSync(uploadsDir, { recursive: true, force: true })
}
await computer.delete()
return response.redirect('/computers')
}
@@ -0,0 +1,32 @@
import type { HttpContext } from '@adonisjs/core/http'
import Computer from '#models/computer'
import ComputerPublicTransformer from '#transformers/computer_public_transformer'
export default class PublicComputerController {
/**
* Publicly display a computer via its share token. No authentication
* required. Only resolves computers with sharing explicitly enabled.
*/
async show({ params, inertia, response }: HttpContext) {
const computer = await Computer.query()
.where('share_token', params.token)
.andWhere('share_enabled', true)
.preload('state')
.preload('components', (componentsQuery) => {
componentsQuery.preload('type')
})
.preload('pictures', (picturesQuery) => {
picturesQuery.orderBy('order')
})
.first()
if (!computer) {
response.status(404)
return inertia.render('errors/not_found', {})
}
return inertia.render('share/show', {
computer: ComputerPublicTransformer.transform(computer),
})
}
}
+109
View File
@@ -0,0 +1,109 @@
import type { HttpContext } from '@adonisjs/core/http'
import vine from '@vinejs/vine'
import ScrapedPart from '#models/scraped_part'
import ComponentType from '#models/component_type'
import {
ensureFreshCategory,
updatePriceIfCheaper,
clearPriceIfNoOffers,
} from '#services/part_scraper_service'
import { findCheapestOffers } from '#services/merchant_price_service'
const SEARCH_RESULTS_LIMIT = 150
const searchValidator = vine.compile(
vine.object({
q: vine.string().trim().minLength(1).optional(),
typeId: vine.number().min(1).optional(),
})
)
const compareValidator = vine.compile(
vine.object({
id: vine.number().min(1),
})
)
export default class ScrapedPartsController {
/**
* Search the new-parts cache (scraped from pc-builder.io) by name and/or
* component type. Read-only, authenticated (the search UI lives inside
* the authenticated "create component" flow).
*
* When a typeId is given, the corresponding category cache is refreshed
* synchronously first if it's missing or older than 24h (see
* part_scraper_service.CACHE_TTL_HOURS), so results stay reasonably
* fresh without needing an external cron job. Free-text searches across
* all types skip this eager refresh to avoid re-scraping every mapped
* category on every keystroke; run `node ace scrape:parts` to refresh
* everything at once.
*
* Results are ordered with a resolved price first (cheapest first), then
* parts with no known price yet (see ScrapedPart.price doc) last — so a
* part is never hidden just because pc-builder.io hasn't found a fixed
* Amazon offer for it, but priced parts still surface first.
*/
async index({ request, auth, response }: HttpContext) {
await auth.authenticate()
const { q, typeId } = await request.validateUsing(searchValidator)
if (typeId) {
const componentType = await ComponentType.find(typeId)
if (componentType) {
await ensureFreshCategory(componentType)
}
}
const query = ScrapedPart.query()
.preload('type')
.orderByRaw('price IS NULL, price ASC, name ASC')
if (q) {
query.whereRaw('LOWER(name) LIKE ?', [`%${q.toLowerCase()}%`])
}
if (typeId) {
query.where('typeId', typeId)
}
const parts = await query.limit(SEARCH_RESULTS_LIMIT)
return response.json(parts.map((part) => part.serialize()))
}
/**
* Compares live prices for a given scraped part across Amazon, LDLC,
* Grosbill, TopAchat, Materiel.net, Cdiscount and PCComponentes, and
* returns them sorted cheapest first. Triggered on demand (one part at a
* time) rather than for every search result, since it makes several
* outbound requests and can take a few seconds.
*
* If the cheapest offer found beats the part's currently stored price
* (or no price was known yet), it's persisted as the new default price
* (see updatePriceIfCheaper) — so this comparison benefits every future
* search/import for this part, not just the current user.
*
* If NO merchant confirms a price at all, the part's price is cleared
* to null (see clearPriceIfNoOffers) rather than silently keeping a
* pc-builder.io catalog price the user just explicitly asked to verify
* and that no longer checks out anywhere.
*/
async compare({ request, auth, response }: HttpContext) {
await auth.authenticate()
const { id } = await request.validateUsing(compareValidator)
const part = await ScrapedPart.find(id)
if (!part) {
return response.notFound({ offers: [] })
}
const amazonUrl = part.sourceUrl.includes('amazon.') ? part.sourceUrl : null
const offers = await findCheapestOffers(part.name, amazonUrl)
if (offers.length > 0) {
await updatePriceIfCheaper(part, offers[0])
} else {
await clearPriceIfNoOffers(part)
}
return response.json({ offers, price: part.price })
}
}
@@ -0,0 +1,17 @@
import { Exception } from '@adonisjs/core/exceptions'
import type { HttpContext } from '@adonisjs/core/http'
/**
* Thrown when a component selection is invalid, either because:
* - the referenced component does not belong to the current user, or
* - more than one component of the same "unique" type is being attached
* to the same computer at once.
*/
export default class InvalidComponentSelectionException extends Exception {
static status = 422
static code = 'E_INVALID_COMPONENT_SELECTION'
handle(error: this, { response }: HttpContext) {
return response.status(error.status).send({ errors: [{ message: error.message }] })
}
}
+3
View File
@@ -24,6 +24,9 @@ export default class Component extends BaseModel {
@column()
declare computerId: number | null
@column()
declare purchaseUrl: string | null
@column({ serializeAs: null })
declare userId: number
+17 -1
View File
@@ -1,6 +1,7 @@
import { DateTime } from 'luxon'
import { BaseModel, belongsTo, column, computed, hasMany } from '@adonisjs/lucid/orm'
import type { BelongsTo, HasMany } from '@adonisjs/lucid/types/relations'
import logger from '@adonisjs/core/services/logger'
import ComputerState from '#models/computer_state'
import ComputerPicture from '#models/computer_picture'
import Component from '#models/component'
@@ -43,6 +44,18 @@ export default class Computer extends BaseModel {
@column({ serializeAs: null })
declare userId: number
@column({ serializeAs: null })
declare shareToken: string | null
@column({ consume: (value: unknown) => Boolean(value) })
declare shareEnabled: boolean
@column({ consume: (value: unknown) => Boolean(value) })
declare shareShowSellPrice: boolean
@column({ consume: (value: unknown) => Boolean(value) })
declare shareShowCost: boolean
@column.dateTime({ autoCreate: true })
declare createdAt: DateTime
@@ -52,7 +65,10 @@ export default class Computer extends BaseModel {
@computed()
get cost(): number {
if (!this.$preloaded.components) {
console.warn('Components not preloaded, cost may be incorrect')
logger.warn(
{ computerId: this.id },
'Computer.cost accessed without preloading "components"; returning 0'
)
return 0
}
return this.components.reduce((total, component) => total + Number(component.price), 0)
+11
View File
@@ -0,0 +1,11 @@
import { belongsTo } from '@adonisjs/lucid/orm'
import type { BelongsTo } from '@adonisjs/lucid/types/relations'
import { ScrapedPartSchema } from '#database/schema'
import ComponentType from '#models/component_type'
export default class ScrapedPart extends ScrapedPartSchema {
@belongsTo(() => ComponentType, {
foreignKey: 'typeId',
})
declare type: BelongsTo<typeof ComponentType>
}
+36 -4
View File
@@ -1,21 +1,53 @@
import Component from '#models/component'
import Computer from '#models/computer'
import InvalidComponentSelectionException from '#exceptions/invalid_component_selection_exception'
async function handleComponents(componentsId: number[], computer: Computer) {
async function handleComponents(componentsId: number[], computer: Computer, userId: number) {
const currentComponents = await computer.related('components').query()
const currentComponentsIds = currentComponents.map((component) => component.id)
const componentsToAdd = componentsId.filter((id) => !currentComponentsIds.includes(id))
const componentsToRemove = currentComponentsIds.filter((id) => !componentsId.includes(id))
const componentsToAddRecords = await Promise.all(
componentsToAdd.map((id: number) =>
Component.query().where('userId', userId).andWhere('id', id).preload('type').firstOrFail()
)
)
// Ensure at most one component of each "unique" type ends up attached to
// the computer once the additions/removals are applied.
const remainingComponentIds = currentComponentsIds.filter(
(id) => !componentsToRemove.includes(id)
)
const remainingComponents = remainingComponentIds.length
? await Component.query().whereIn('id', remainingComponentIds).preload('type')
: []
const finalComponents = [...remainingComponents, ...componentsToAddRecords]
const uniqueTypeCounts = new Map<number, number>()
for (const component of finalComponents) {
if (!component.type.unique) continue
const count = (uniqueTypeCounts.get(component.type.id) ?? 0) + 1
uniqueTypeCounts.set(component.type.id, count)
if (count > 1) {
throw new InvalidComponentSelectionException(
`Only one component of type "${component.type.name}" can be attached to a computer at a time.`
)
}
}
await Promise.all(
componentsToAdd.map(async (id: number) => {
const component = await Component.findOrFail(id)
componentsToAddRecords.map(async (component) => {
component.computerId = computer.id
await component.save()
})
)
await Promise.all(
componentsToRemove.map(async (id: number) => {
const component = await Component.findOrFail(id)
const component = await Component.query()
.where('userId', userId)
.andWhere('id', id)
.firstOrFail()
component.computerId = null
await component.save()
})
+50
View File
@@ -0,0 +1,50 @@
import { randomBytes } from 'node:crypto'
import Computer from '#models/computer'
/**
* Generates a URL-safe, non-guessable share token.
*/
function generateShareToken(): string {
return randomBytes(24).toString('base64url')
}
/**
* Enables sharing for a computer, generating a share token if it doesn't
* already have one. Idempotent: calling it again just updates the display
* options without rotating the existing token.
*/
async function enableSharing(
computer: Computer,
options: { showSellPrice: boolean; showCost: boolean }
) {
if (!computer.shareToken) {
computer.shareToken = generateShareToken()
}
computer.shareEnabled = true
computer.shareShowSellPrice = options.showSellPrice
computer.shareShowCost = options.showCost
await computer.save()
return computer
}
/**
* Disables sharing for a computer. The share token is kept so re-enabling
* later doesn't require distributing a new link, but the public route only
* resolves computers where shareEnabled is true.
*/
async function disableSharing(computer: Computer) {
computer.shareEnabled = false
await computer.save()
return computer
}
/**
* Rotates the share token, invalidating any previously distributed link.
*/
async function rotateShareToken(computer: Computer) {
computer.shareToken = generateShareToken()
await computer.save()
return computer
}
export { enableSharing, disableSharing, rotateShareToken }
+69
View File
@@ -0,0 +1,69 @@
import { chromium, type Browser } from 'playwright-core'
import env from '#start/env'
import logger from '@adonisjs/core/services/logger'
const USER_AGENT =
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
let browserPromise: Promise<Browser> | null = null
/**
* Lazily launches a single shared headless Chromium instance, reused
* across requests instead of paying the ~1-2s browser startup cost on
* every merchant lookup. Points at a system-installed Chromium/Chrome
* binary via CHROMIUM_EXECUTABLE_PATH (see .env.example) rather than
* bundling Playwright's own ~300MB download.
*/
function getBrowser(): Promise<Browser> {
if (!browserPromise) {
browserPromise = chromium
.launch({
executablePath: env.get('CHROMIUM_EXECUTABLE_PATH') || undefined,
headless: true,
args: ['--no-sandbox', '--disable-setuid-sandbox'],
})
.catch((error) => {
browserPromise = null
throw error
})
}
return browserPromise
}
/**
* Runs `fn` with a fresh browser page/context, always closing the context
* afterwards (success or failure) so pages don't accumulate across many
* lookups. The underlying browser process itself stays warm between calls.
* Returns null (rather than throwing) if the browser fails to launch or the
* callback throws, since merchant lookups are best-effort.
*/
async function withPage<T>(
fn: (page: import('playwright-core').Page) => Promise<T>
): Promise<T | null> {
try {
const browser = await getBrowser()
const context = await browser.newContext({ userAgent: USER_AGENT, locale: 'fr-FR' })
try {
const page = await context.newPage()
return await fn(page)
} finally {
await context.close()
}
} catch (error) {
logger.warn({ err: error }, 'Headless browser lookup failed')
return null
}
}
/**
* Closes the shared browser instance, if running. Intended for graceful
* shutdown hooks / tests, not needed during normal request handling.
*/
async function closeBrowser(): Promise<void> {
if (!browserPromise) return
const browser = await browserPromise.catch(() => null)
browserPromise = null
await browser?.close()
}
export { withPage, closeBrowser }
@@ -0,0 +1,82 @@
import { DateTime } from 'luxon'
import logger from '@adonisjs/core/services/logger'
import ComponentType from '#models/component_type'
import ScrapedPart from '#models/scraped_part'
import { findCheapestOffers } from '#services/merchant_price_service'
const SOURCE = 'manual'
/**
* Very recent parts pc-builder.io doesn't carry yet (its catalog appears
* to lag several months behind actual product releases — confirmed via
* its own server-side search returning 0 results for "RTX 5060"). These
* are seeded manually so they're at least searchable/importable, with
* their price resolved live from the merchants merchant_price_service.ts
* already supports (LDLC/Grosbill both carry the RTX 5060 series, for
* example) rather than a hardcoded number that would go stale immediately.
*
* sourceRef must be stable and unique per entry (used as the upsert key,
* like pc-builder.io's own product ID), and is namespaced under
* "manual-" to guarantee it can never collide with a numeric
* pc-builder.io data-id.
*/
const MANUAL_PARTS: { typeName: string; name: string; sourceRef: string }[] = [
{
typeName: 'Graphic Card',
name: 'NVIDIA GeForce RTX 5060 8 GB Video Card',
sourceRef: 'manual-rtx-5060-8gb',
},
{
typeName: 'Graphic Card',
name: 'NVIDIA GeForce RTX 5060 Ti 16 GB Video Card',
sourceRef: 'manual-rtx-5060-ti-16gb',
},
]
/**
* Upserts every entry in MANUAL_PARTS, resolving each one's price live
* from the supported merchants (Amazon/LDLC/Grosbill/...). An entry with
* no ComponentType match in the current database, or no merchant offer
* found at all, is skipped rather than inserted with a null/guessed
* price — this only fills in real, verified prices.
*/
async function seedManualParts(): Promise<{ inserted: number; skipped: number }> {
let inserted = 0
let skipped = 0
for (const manualPart of MANUAL_PARTS) {
const componentType = await ComponentType.findBy('name', manualPart.typeName)
if (!componentType) {
logger.warn({ part: manualPart.name }, 'No matching ComponentType, skipping manual part')
skipped++
continue
}
const offers = await findCheapestOffers(manualPart.name, null)
if (offers.length === 0) {
logger.warn({ part: manualPart.name }, 'No merchant offer found, skipping manual part')
skipped++
continue
}
const cheapest = offers[0]
await ScrapedPart.updateOrCreate(
{ source: SOURCE, sourceRef: manualPart.sourceRef },
{
typeId: componentType.id,
name: manualPart.name,
imageUrl: null,
price: cheapest.price,
sourceUrl: cheapest.url,
priceSource: cheapest.merchant,
priceCheckedAt: DateTime.now(),
scrapedAt: DateTime.now(),
}
)
inserted++
}
return { inserted, skipped }
}
export { seedManualParts, MANUAL_PARTS }
+525
View File
@@ -0,0 +1,525 @@
import * as cheerio from 'cheerio'
import logger from '@adonisjs/core/services/logger'
import { withPage } from '#services/headless_browser_service'
const USER_AGENT =
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
export interface MerchantOffer {
merchant:
| 'Amazon'
| 'LDLC'
| 'Grosbill'
| 'TopAchat'
| 'Materiel.net'
| 'Cdiscount'
| 'PCComponentes'
price: number
url: string
}
async function fetchHtml(url: string): Promise<string | null> {
try {
const response = await fetch(url, {
headers: {
'User-Agent': USER_AGENT,
'Accept-Language': 'fr-FR,fr;q=0.9',
},
})
if (!response.ok) return null
return await response.text()
} catch (error) {
logger.warn({ url, err: error }, 'Failed to fetch merchant page')
return null
}
}
/**
* Parses a price from plain text like "64,95 €" or "413.59 €*".
*/
function parsePriceText(text: string): number | null {
const normalized = text
.replace(/\u00a0/g, ' ')
.replace(/[^\d,.\s]/g, '')
.trim()
.replace(/\s/g, '')
.replace(',', '.')
const value = Number.parseFloat(normalized)
return Number.isNaN(value) ? null : value
}
/**
* Some merchants (LDLC, Grosbill, Materiel.net) render prices split across
* sibling/child elements instead of plain text, e.g.
* `<div class="price">64€<sup>95</sup></div>` renders as "64€95" once
* flattened to text, which parsePriceText would misread as 6495 (or, worse,
* "2 049€95" as 204995). This detects that "<whole>€<cents>" shape in
* flattened text and recombines it as <whole>.<cents> instead.
*/
function parseFlattenedFragmentedPriceText(text: string): number | null {
const match = text
.replace(/\u00a0/g, ' ')
.trim()
.match(/^([\d\s]+)\s*€\s*(\d{1,2})$/)
if (!match) return null
const whole = match[1].replace(/\s/g, '')
const cents = match[2].padEnd(2, '0').slice(0, 2)
const value = Number.parseFloat(`${whole}.${cents}`)
return Number.isNaN(value) ? null : value
}
/**
* Same fragmented-price shape as parseFlattenedFragmentedPriceText, but
* operating on a Cheerio element with a real `<sup>` child instead of
* already-flattened text — used when scraping static HTML with cheerio
* (LDLC, Grosbill) rather than reading `.innerText()` from a live browser
* page (Materiel.net).
*
* LDLC additionally nests a duplicate `.price` wrapper
* (`<div class="price"><div class="price">64€<sup>95</sup></div></div>`),
* so this always resolves to the innermost element actually containing a
* `<sup>`, rather than assuming $el itself is the leaf node.
*/
function parseFragmentedPrice($el: cheerio.Cheerio<any>): number | null {
const sup = $el.find('sup').first()
if (sup.length > 0) {
// The element that directly holds both the whole-part text node and
// the <sup> cents — not necessarily $el itself if it's a wrapper.
const leaf = sup.parent()
const cents = sup.text().replace(/\D/g, '')
const wholePart = leaf.clone().children().remove().end().text().replace(/\D/g, '')
if (wholePart) {
const value = Number.parseFloat(`${wholePart}.${cents.padEnd(2, '0').slice(0, 2)}`)
return Number.isNaN(value) ? null : value
}
}
return parsePriceText($el.text())
}
/**
* Builds a short, merchant-search-friendly query from a full pc-builder.io
* product name. These sites' search engines are strict/exact-match and
* choke on the long, spec-heavy names pc-builder.io uses (e.g. "AMD Ryzen 3
* 3200G 3.6 GHz Quad-Core Processor" returns zero results everywhere, but
* "AMD Ryzen 3" does). We search on the first few tokens (brand + series),
* then use extractModelToken() to pick out the right result from the list.
*/
function shortSearchQuery(name: string): string {
const tokens = name
.replace(/\([^)]*\)/g, ' ')
.split(/\s+/)
.filter(Boolean)
return tokens.slice(0, 3).join(' ')
}
/**
* Suffix tokens that modify a base GPU/CPU model number into a distinct
* SKU with a different price (e.g. "RTX 5060" vs "RTX 5060 Ti", "RTX 4070"
* vs "RTX 4070 Super"). When immediately following the model token, these
* must be treated as part of it — otherwise "5060 Ti" only matches on
* "5060" and can't be told apart from plain "5060".
*/
const MODEL_SUFFIXES = ['Ti', 'Super', 'XT', 'XTX']
/**
* Extracts the most specific "model number" token from a product name, to
* disambiguate between multiple search results (e.g. "3200G" out of "AMD
* Ryzen 3 3200G ...", "970" out of "Samsung 970 Evo Plus ...", or
* "5060 Ti" out of "NVIDIA GeForce RTX 5060 Ti 16 GB ..." — as opposed to
* plain "5060", which is a different, cheaper card). Prefers alphanumeric
* tokens that mix letters and digits (typical of CPU/GPU model numbers),
* falling back to a bare 3-4 digit number.
*/
function extractModelToken(name: string): string | null {
const tokens = name
.replace(/\([^)]*\)/g, ' ')
.split(/\s+/)
.filter(Boolean)
const matchWithSuffix = (index: number, base: string): string => {
const next = tokens[index + 1]
return next && MODEL_SUFFIXES.includes(next) ? `${base} ${next}` : base
}
const mixedIndex = tokens.findIndex(
(t) => /[a-zA-Z]/.test(t) && /\d/.test(t) && !/^\d+(\.\d+)?ghz$/i.test(t)
)
if (mixedIndex !== -1) return matchWithSuffix(mixedIndex, tokens[mixedIndex])
const bareIndex = tokens.findIndex((t) => /^\d{3,4}$/.test(t))
if (bareIndex !== -1) return matchWithSuffix(bareIndex, tokens[bareIndex])
return null
}
/**
* True if `label` matches `modelToken` as the specific SKU it represents,
* not just as a loose substring. Plain string.includes() would let "5060"
* match "RTX 5060 Ti" (wrong, more expensive/different card) and vice
* versa "5060 Ti" would still substring-match "5060" only by accident of
* word order — this instead requires the model token to appear as its own
* word boundary, and additionally rejects a bare model number (no suffix)
* when the label actually has one of MODEL_SUFFIXES right after it.
*/
function labelMatchesModelToken(label: string, modelToken: string): boolean {
const normalizedLabel = label.toLowerCase()
const normalizedToken = modelToken.toLowerCase()
if (!normalizedLabel.includes(normalizedToken)) return false
const hasSuffix = normalizedToken.includes(' ')
if (hasSuffix) return true
// Bare token (e.g. "5060"): reject if the label has it immediately
// followed by a known suffix (e.g. "5060 Ti"), since that's a distinct,
// different-priced SKU we should not conflate with the plain model.
const suffixPattern = MODEL_SUFFIXES.join('|')
const followedBySuffix = new RegExp(`\\b${normalizedToken}\\s+(${suffixPattern})\\b`, 'i')
return !followedBySuffix.test(label)
}
/**
* Reads the price directly from a known Amazon.fr product page
* (https://www.amazon.fr/dp/{ASIN}?...). Only works for direct product
* links; Amazon search-result links (https://www.amazon.fr/s?k=...) can't
* be resolved to a single price this way.
*/
async function fetchAmazonPrice(amazonUrl: string): Promise<MerchantOffer | null> {
if (!amazonUrl.includes('/dp/')) return null
const html = await fetchHtml(amazonUrl)
if (!html) return null
const $ = cheerio.load(html)
const priceText = $('.a-price .a-offscreen').first().text().trim()
const price = parsePriceText(priceText)
if (price === null) return null
return { merchant: 'Amazon', price, url: amazonUrl }
}
/**
* Searches LDLC by product name and reads the price of the result whose
* label contains the extracted model token (e.g. "3200G"). Only falls back
* to the first result when there is no model token to disambiguate with
* (rare); otherwise a search that returns unrelated products (LDLC simply
* doesn't carry this exact model) correctly yields no offer instead of a
* misleading price for the wrong product.
*/
async function fetchLdlcPrice(productName: string): Promise<MerchantOffer | null> {
const query = shortSearchQuery(productName)
const modelToken = extractModelToken(productName)
const url = `https://www.ldlc.com/recherche/${encodeURIComponent(query)}/`
const html = await fetchHtml(url)
if (!html) return null
const $ = cheerio.load(html)
const items = $('.listing-product li.pdt-item')
if (!items.length) return null
let best: cheerio.Cheerio<any> | null = modelToken ? null : items.first()
if (modelToken) {
items.each((_, el) => {
const label = $(el).find('.pdt-desc a').first().text()
if (labelMatchesModelToken(label, modelToken)) {
best = $(el)
return false
}
})
}
if (!best) return null
const link = (best as cheerio.Cheerio<any>).find('.pdt-desc a').first().attr('href')
const priceEl = (best as cheerio.Cheerio<any>).find('.price').first()
const price = parseFragmentedPrice(priceEl)
if (price === null || !link) return null
return { merchant: 'LDLC', price, url: new URL(link, 'https://www.ldlc.com').toString() }
}
/**
* Searches Grosbill by product name and reads the price of the result
* whose label contains the extracted model token. Same rationale as
* fetchLdlcPrice: without a model token match, we return null rather than
* risk quoting the price of an unrelated product.
*/
async function fetchGrosbillPrice(productName: string): Promise<MerchantOffer | null> {
const query = shortSearchQuery(productName)
const modelToken = extractModelToken(productName)
const url = `https://www.grosbill.com/produit.aspx?q=${encodeURIComponent(query)}/`
const html = await fetchHtml(url)
if (!html) return null
const $ = cheerio.load(html)
const items = $('[class*="la_liste_des_produits"]')
if (!items.length) return null
let best: cheerio.Cheerio<any> | null = modelToken ? null : items.first()
if (modelToken) {
items.each((_, el) => {
const label = $(el)
.find('.grb__liste-produit__liste__produit__information__libelle')
.text()
.replace(/\s+/g, ' ')
if (labelMatchesModelToken(label, modelToken)) {
best = $(el)
return false
}
})
}
if (!best) return null
const priceEl = (best as cheerio.Cheerio<any>).find('.price-main').first()
const price = parseFragmentedPrice(priceEl)
if (price === null) return null
const link = (best as cheerio.Cheerio<any>).find('a[href]').first().attr('href')
return { merchant: 'Grosbill', price, url: link ? new URL(link, url).toString() : url }
}
/**
* Searches TopAchat by product name. When the search resolves to a single
* matching product, TopAchat redirects straight to the product page
* (label-product / offer-price__price). List-page matching by model token
* is attempted first for queries with multiple hits.
*/
async function fetchTopAchatPrice(productName: string): Promise<MerchantOffer | null> {
const query = shortSearchQuery(productName)
const modelToken = extractModelToken(productName)
const url = `https://www.topachat.com/search/${encodeURIComponent(query)}`
const html = await fetchHtml(url)
if (!html) return null
const $ = cheerio.load(html)
// Case 1: redirected straight to a single product page.
const singlePrice = $('.offer-price__price').first()
if (singlePrice.length) {
const label = $('.label-product').first().text()
if (!modelToken || labelMatchesModelToken(label, modelToken)) {
const priceText = singlePrice.clone().children().remove().end().text().trim()
const price = parsePriceText(priceText)
if (price !== null) {
const canonical = $('link[rel="canonical"]').attr('href')
return { merchant: 'TopAchat', price, url: canonical || url }
}
}
}
// Case 2: a list of results (product__price / product__label cards).
const cards = $('article.product, [class*="product__"]').filter(
(_, el) => $(el).find('.product__price').length > 0
)
let best: cheerio.Cheerio<any> | null = null
cards.each((_, el) => {
const label = $(el).find('.product__label').text()
if (!modelToken || labelMatchesModelToken(label, modelToken)) {
best = $(el)
return false
}
})
if (!best) return null
const priceText = (best as cheerio.Cheerio<any>).find('.product__price').first().text().trim()
const price = parsePriceText(priceText)
if (price === null) return null
const link = (best as cheerio.Cheerio<any>).closest('a[href]').attr('href')
return { merchant: 'TopAchat', price, url: link ? new URL(link, url).toString() : url }
}
/**
* Searches Materiel.net by simulating the real search form submission in a
* headless browser. A plain GET on /recherche/?query=... always returns an
* empty "no results" template — the search only actually runs client-side
* (Stimulus/Turbo), so this is one of three merchants where a real browser
* is required rather than a plain HTTP fetch.
*
* When there's a single match, Materiel.net redirects straight to the
* product page. Otherwise it lists cards matched by model token.
*/
async function fetchMaterielNetPrice(productName: string): Promise<MerchantOffer | null> {
const query = shortSearchQuery(productName)
const modelToken = extractModelToken(productName)
return withPage(async (page) => {
await page.goto('https://www.materiel.net/', { waitUntil: 'networkidle', timeout: 20000 })
await page.fill('#search_search_text', query)
await page.click('button[type="submit"]')
await page.waitForLoadState('networkidle', { timeout: 20000 })
// Case 1: redirected straight to a single product page (URL pattern
// distinguishes this from Case 2 — a list page also has
// .o-product__price elements, once per card, so checking for its mere
// presence isn't enough to tell the two cases apart).
if (/\/produit\//.test(page.url())) {
const title = await page.title()
if (!modelToken || labelMatchesModelToken(title, modelToken)) {
const text = await page
.locator('.o-product__price')
.first()
.innerText()
.catch(() => '')
const price = parseFlattenedFragmentedPriceText(text) ?? parsePriceText(text)
if (price !== null) {
return { merchant: 'Materiel.net' as const, price, url: page.url() }
}
}
return null
}
// Case 2: a list of results.
const cards = page.locator('li.c-products-list__item[data-id]')
const count = await cards.count()
for (let i = 0; i < count; i++) {
const card = cards.nth(i)
const label = await card
.locator('.c-product__meta h2.c-product__title')
.innerText()
.catch(() => '')
if (modelToken && !labelMatchesModelToken(label, modelToken)) continue
const priceText = await card
.locator('.o-product__price')
.first()
.innerText()
.catch(() => '')
const price = parseFlattenedFragmentedPriceText(priceText) ?? parsePriceText(priceText)
if (price === null) continue
const link = await card
.locator('.c-product__meta a.c-product__link')
.first()
.getAttribute('href')
return { merchant: 'Materiel.net' as const, price, url: link ?? page.url() }
}
return null
})
}
/**
* Searches Cdiscount by product name via a headless browser. Cdiscount's
* search-results page returns an empty body to plain HTTP fetches (no
* error, just nothing), which is consistent with the listing being
* rendered client-side and/or gated behind bot-detection that only lets
* real browsers through.
*/
async function fetchCdiscountPrice(productName: string): Promise<MerchantOffer | null> {
const query = shortSearchQuery(productName)
const modelToken = extractModelToken(productName)
const url = `https://www.cdiscount.com/search/10/${encodeURIComponent(query)}.html`
return withPage(async (page) => {
await page.goto(url, { waitUntil: 'networkidle', timeout: 20000 })
const cards = page.locator('div[data-e2e="offer-wrapper"]')
const count = await cards.count()
for (let i = 0; i < count; i++) {
const card = cards.nth(i)
const label = await card
.locator('[data-e2e="lplr-title"]')
.innerText()
.catch(() => '')
if (modelToken && !labelMatchesModelToken(label, modelToken)) continue
const priceText = await card
.locator('[data-e2e="lplr-price"]')
.first()
.innerText()
.catch(() => '')
const price = parsePriceText(priceText)
if (price === null) continue
const link = await card.locator('a').first().getAttribute('href')
const offerUrl = link ? new URL(link, 'https://www.cdiscount.com').toString() : url
return { merchant: 'Cdiscount' as const, price, url: offerUrl }
}
return null
})
}
/**
* Searches PCComponentes.com by product name via a headless browser
* (blocked with a plain HTTP 403 otherwise). Product cards expose clean,
* pre-parsed data on the <a> element itself (data-product-price as a plain
* number, data-product-category to distinguish standalone components from
* full "Sobremesa" pre-built PCs), so full-PC listings that happen to
* mention the same CPU are excluded rather than misreported as its price.
*/
async function fetchPcComponentesPrice(productName: string): Promise<MerchantOffer | null> {
const query = shortSearchQuery(productName)
const modelToken = extractModelToken(productName)
const url = `https://www.pccomponentes.com/buscar/?query=${encodeURIComponent(query)}`
return withPage(async (page) => {
await page.goto(url, { waitUntil: 'networkidle', timeout: 20000 })
const cards = page.locator('#search-results-product-grid > a[data-product-id]')
const count = await cards.count()
for (let i = 0; i < count; i++) {
const card = cards.nth(i)
const category = await card.getAttribute('data-product-category')
if (category === 'Sobremesa') continue // full pre-built PC, not the component itself
const name = (await card.getAttribute('data-product-name')) ?? ''
if (modelToken && !labelMatchesModelToken(name, modelToken)) continue
const priceAttr = await card.getAttribute('data-product-price')
const price = priceAttr ? Number.parseFloat(priceAttr) : null
if (price === null || Number.isNaN(price)) continue
const href = await card.getAttribute('href')
const offerUrl = href ? new URL(href, 'https://www.pccomponentes.com').toString() : url
return { merchant: 'PCComponentes' as const, price, url: offerUrl }
}
return null
})
}
/**
* Fetches offers from every supported merchant in parallel and returns
* them sorted by ascending price. Merchants that fail (blocked, no match,
* network error) are silently skipped rather than failing the whole
* comparison.
*/
async function findCheapestOffers(
productName: string,
amazonUrl: string | null
): Promise<MerchantOffer[]> {
const lookups: Promise<MerchantOffer | null>[] = [
amazonUrl ? fetchAmazonPrice(amazonUrl) : Promise.resolve(null),
fetchLdlcPrice(productName),
fetchGrosbillPrice(productName),
fetchTopAchatPrice(productName),
fetchMaterielNetPrice(productName),
fetchCdiscountPrice(productName),
fetchPcComponentesPrice(productName),
]
const results = await Promise.allSettled(lookups)
const offers = results
.filter(
(result): result is PromiseFulfilledResult<MerchantOffer | null> =>
result.status === 'fulfilled'
)
.map((result) => result.value)
.filter((offer): offer is MerchantOffer => offer !== null)
return offers.sort((a, b) => a.price - b.price)
}
export {
findCheapestOffers,
fetchAmazonPrice,
fetchLdlcPrice,
fetchGrosbillPrice,
fetchTopAchatPrice,
fetchMaterielNetPrice,
fetchCdiscountPrice,
fetchPcComponentesPrice,
extractModelToken,
labelMatchesModelToken,
}
+451
View File
@@ -0,0 +1,451 @@
import * as cheerio from 'cheerio'
import { DateTime } from 'luxon'
import logger from '@adonisjs/core/services/logger'
import ComponentType from '#models/component_type'
import ScrapedPart from '#models/scraped_part'
const SOURCE = 'pc-builder.io'
const BASE_URL = 'https://fr.pc-builder.io'
const USER_AGENT = 'Mozilla/5.0 (compatible; PcBuilderPartFinder/1.0)'
const PAGE_SIZE = 100
/**
* Default freshness window for the scraped_parts cache. A category is
* re-scraped automatically the first time it is searched after this delay
* has elapsed since its last successful scrape.
*/
const CACHE_TTL_HOURS = 24
/**
* How many products to fetch when a search triggers an automatic refresh
* (see ensureFreshCategory). Kept small since this runs synchronously
* inside a user-facing HTTP request — a full deep scrape (thousands of
* products for some categories) belongs in the `scrape:parts` command
* instead, not in the request/response cycle of a search.
*/
const QUICK_REFRESH_MAX_PRODUCTS = 200
/**
* Maps our own ComponentType names to pc-builder.io's category slug,
* numeric category_id (required by the paginated /products/data endpoint,
* found in each category page's inline DataTables config), and how many
* products `node ace scrape:parts` should fetch for that category.
*
* maxProducts is chosen per category based on how much price variance
* matters there and how large the catalog is: GPUs get close-to-full
* coverage since price differences between models are large and users
* specifically look for cheaper alternatives (see the RTX 5070 case that
* prompted this), while smaller/less price-sensitive categories use a
* lighter cap to keep the full scrape run in a reasonable time.
*
* Note: pc-builder.io only has a single "storage" category covering both
* SSDs and HDDs (no separate slugs exist), so it is only mapped to our
* "SSD" type to avoid two types racing to own the same scraped rows
* (source_ref is unique per source, independent of type_id).
*/
const CATEGORY_CONFIG: Record<string, { slug: string; categoryId: string; maxProducts: number }> = {
'Processor': { slug: 'cpu', categoryId: '1', maxProducts: 500 },
'Motherboard': { slug: 'motherboard', categoryId: '3', maxProducts: 500 },
'Graphic Card': { slug: 'gpu', categoryId: '6', maxProducts: 6000 },
'Memory': { slug: 'ram', categoryId: '4', maxProducts: 800 },
'SSD': { slug: 'storage', categoryId: '5', maxProducts: 800 },
'Cooler': { slug: 'cpu-cooler', categoryId: '2', maxProducts: 400 },
'Power Supply': { slug: 'power-supply', categoryId: '8', maxProducts: 400 },
'Case': { slug: 'case', categoryId: '7', maxProducts: 400 },
'Fan': { slug: 'case-fan', categoryId: '20', maxProducts: 300 },
'WiFi-Card': { slug: 'wireless-network-adapter', categoryId: '13', maxProducts: 400 },
}
/** Backwards-compatible slug lookup, kept for anything relying on it. */
const CATEGORY_SLUGS: Record<string, string> = Object.fromEntries(
Object.entries(CATEGORY_CONFIG).map(([name, config]) => [name, config.slug])
)
interface ScrapedRow {
name: string
/**
* Null when pc-builder.io has no fixed offer for this part (common for
* very recent products — it only links to a generic Amazon search,
* e.g. "https://amazon.fr/s?k=..." instead of a priced "/dp/{ASIN}"
* page). Such parts are still kept (not skipped) so they remain
* searchable and importable; ScrapedPartsController#compare lets the
* user resolve — and persist — a real price for them on demand from
* the other supported merchants.
*/
price: number | null
imageUrl: string | null
/**
* The actual merchant page where this part can be bought (currently
* always an Amazon.fr link, since that's the only "buy" link pc-builder.io
* exposes on its category pages) — either a direct priced product page
* or a generic search results page when no price is known. This is
* what gets stored as ScrapedPart.sourceUrl and later copied into
* Component.purchaseUrl — it intentionally is NOT the pc-builder.io
* product page, which isn't a place a user can actually purchase
* anything from.
*/
purchaseUrl: string
sourceRef: string
}
/**
* Loads a category page once to grab the CSRF token and session cookie
* needed to call the paginated /products/data endpoint, which is what
* powers the client-side DataTables widget on every category page.
*/
async function getSessionAndToken(slug: string): Promise<{ cookies: string; token: string }> {
const response = await fetch(`${BASE_URL}/${slug}`, {
headers: { 'User-Agent': USER_AGENT },
})
if (!response.ok) {
throw new Error(`Failed to load ${slug} category page: HTTP ${response.status}`)
}
const html = await response.text()
const setCookieHeaders = response.headers.getSetCookie?.() ?? []
const cookies = setCookieHeaders.map((cookie) => cookie.split(';')[0]).join('; ')
const tokenMatch = html.match(/"_token":\s*"([^"]+)"/)
if (!tokenMatch) {
throw new Error(`Could not find CSRF token on ${slug} category page`)
}
return { cookies, token: tokenMatch[1] }
}
/**
* Parses the "Name" / "Add to Build" cell HTML fragments returned by
* /products/data into a single row, using the exact same markup structure
* as the plain category page (both come from the same Blade partials), so
* the same selectors apply.
*/
function parseRow(nameHtml: string, addToBuildHtml: string): ScrapedRow | null {
const $name = cheerio.load(nameHtml)
const nameLink = $name('a').first()
const name = nameLink.text().trim()
const productPageUrl = nameLink.attr('href')
const img = $name('img.product-image')
const imageUrl = img.attr('data-src') || img.attr('src') || null
const $btn = cheerio.load(addToBuildHtml)
const priceText = $btn('.price-box p').first().text().trim()
const sourceRef = $btn('a.add-to-build').attr('data-id')
const amazonUrl = $btn('a.btn-default').attr('href')
if (!name || !productPageUrl || !sourceRef) {
// Only truly unusable rows (missing name/link/id) are skipped. A
// missing price is not disqualifying — see the ScrapedRow.price doc.
return null
}
let price: number | null = null
if (priceText) {
const parsed = Number.parseFloat(priceText.replace(/[^\d.,]/g, '').replace(',', '.'))
price = Number.isNaN(parsed) ? null : parsed
}
return { name, price, imageUrl, purchaseUrl: amazonUrl || productPageUrl, sourceRef }
}
/**
* Fetches one page of `length` products starting at `start` from
* pc-builder.io's DataTables-backed /products/data endpoint. This is a
* plain HTTP POST (no headless browser needed) once the CSRF
* token/session cookie from getSessionAndToken() are supplied — see
* app/services/part_scraper_service.ts git history / PR description for
* the reverse-engineered request shape (field names matter exactly:
* columns[].data must be "Name"/"Add to Build", not numeric indices).
*/
async function fetchProductsPage(
categoryId: string,
cookies: string,
token: string,
start: number,
length: number
): Promise<{ rows: ScrapedRow[]; recordsTotal: number }> {
const body = new URLSearchParams({
'_token': token,
'draw': '1',
'start': String(start),
'length': String(length),
'compatibility_filter': 'false',
'filter_status': '',
'category_id': categoryId,
'compatible_with': '',
'checkbox_filters': '[]',
'range_filters': '[]',
'search_query': '',
'columns[0][data]': 'Name',
'columns[0][name]': 'Name',
'columns[0][searchable]': 'true',
'columns[0][orderable]': 'false',
'columns[0][search][value]': '',
'columns[0][search][regex]': 'false',
'columns[1][data]': 'Add to Build',
'columns[1][name]': 'Add to Build',
'columns[1][searchable]': 'true',
'columns[1][orderable]': 'false',
'columns[1][search][value]': '',
'columns[1][search][regex]': 'false',
'search[value]': '',
'search[regex]': 'false',
})
const response = await fetch(`${BASE_URL}/products/data`, {
method: 'POST',
headers: {
'User-Agent': USER_AGENT,
'X-Requested-With': 'XMLHttpRequest',
'Content-Type': 'application/x-www-form-urlencoded',
'Cookie': cookies,
},
body,
})
if (!response.ok) {
throw new Error(`products/data request failed: HTTP ${response.status}`)
}
const json = (await response.json()) as {
recordsTotal: number
data: { 'Name': string; 'Add to Build': string }[]
}
const rows = json.data
.map((entry) => parseRow(entry.Name, entry['Add to Build']))
.filter((row): row is ScrapedRow => row !== null)
return { rows, recordsTotal: json.recordsTotal }
}
/**
* Fetches up to `maxProducts` products for a category by paginating
* through pc-builder.io's /products/data endpoint PAGE_SIZE at a time,
* stopping early once the category's real total is exhausted. A short
* delay between pages avoids hammering the site with a burst of requests
* back-to-back for the larger categories (e.g. GPU's ~60 pages).
*/
async function fetchCategoryProducts(
slug: string,
categoryId: string,
maxProducts: number
): Promise<ScrapedRow[]> {
const { cookies, token } = await getSessionAndToken(slug)
const rows: ScrapedRow[] = []
let start = 0
let recordsTotal = maxProducts
while (start < maxProducts && start < recordsTotal) {
const length = Math.min(PAGE_SIZE, maxProducts - start)
const page = await fetchProductsPage(categoryId, cookies, token, start, length)
rows.push(...page.rows)
recordsTotal = page.recordsTotal
start += length
if (start < maxProducts && start < recordsTotal) {
await new Promise((resolve) => setTimeout(resolve, 150))
}
}
return rows
}
/**
* Decides whether a catalog refresh row should overwrite an existing
* ScrapedPart's price fields, or leave them untouched.
*
* Only a price that was actually *verified* via a merchant comparison
* (priceSource set to a merchant name by updatePriceIfCheaper, e.g.
* "LDLC") is protected from being overwritten by a plain catalog refresh
* — that price was confirmed live on the merchant's own page.
*
* A price with priceSource === null just came from a *previous*
* pc-builder.io catalog scrape, with no independent verification.
* pc-builder.io's own listings change constantly — a GPU can have a fixed
* Amazon price today and only a generic "search on Amazon" link (no
* price) tomorrow, if the ASIN it referenced runs out of stock. Treating
* that old, unverified pc-builder.io price as untouchable would freeze it
* forever even after the site itself removed it, which is exactly the
* bug this function fixes (a €431.17 price kept showing for a GPU
* pc-builder.io no longer prices at all).
*/
function shouldKeepExistingPrice(
existing: { price: number | null; priceSource: string | null } | null,
incomingPrice: number | null
): boolean {
if (!existing || existing.price === null || existing.priceSource === null) return false
return incomingPrice === null || existing.price <= incomingPrice
}
/**
* Scrapes a single component type's mapped category and upserts the
* resulting rows into the scraped_parts cache table.
*
* @param maxProductsOverride Caps how many products are fetched for this
* run, regardless of the category's configured maxProducts. Used by
* ensureFreshCategory to keep an on-demand refresh fast (a handful of
* pages) while `node ace scrape:parts` uses the full configured depth.
*/
async function scrapeComponentType(
componentType: ComponentType,
maxProductsOverride?: number
): Promise<number> {
const config = CATEGORY_CONFIG[componentType.name]
if (!config) {
logger.warn({ type: componentType.name }, 'No pc-builder.io category mapped, skipping')
return 0
}
const maxProducts = Math.min(maxProductsOverride ?? config.maxProducts, config.maxProducts)
const rows = await fetchCategoryProducts(config.slug, config.categoryId, maxProducts)
const scrapedAt = DateTime.now()
for (const row of rows) {
const existing = await ScrapedPart.query()
.where('source', SOURCE)
.andWhere('sourceRef', row.sourceRef)
.first()
const keepExistingPrice = shouldKeepExistingPrice(existing, row.price)
await ScrapedPart.updateOrCreate(
{ source: SOURCE, sourceRef: row.sourceRef },
{
typeId: componentType.id,
name: row.name,
imageUrl: row.imageUrl,
scrapedAt,
...(keepExistingPrice
? {}
: {
price: row.price,
sourceUrl: row.purchaseUrl,
priceSource: row.price !== null ? SOURCE : null,
priceCheckedAt: row.price !== null ? scrapedAt : null,
}),
}
)
}
return rows.length
}
/**
* Scrapes every mapped ComponentType category from pc-builder.io and
* refreshes the scraped_parts cache, using each category's full
* configured depth (see CATEGORY_CONFIG). Returns a per-type summary.
* This can take a few minutes overall (GPU alone fetches up to 6000
* products across ~60 requests) — intended to be run manually or via a
* scheduled job, not inside a request.
*/
async function scrapeAllCategories(): Promise<Record<string, number>> {
const componentTypes = await ComponentType.all()
const summary: Record<string, number> = {}
for (const componentType of componentTypes) {
if (!CATEGORY_CONFIG[componentType.name]) continue
try {
summary[componentType.name] = await scrapeComponentType(componentType)
} catch (error) {
logger.error({ type: componentType.name, err: error }, 'Failed to scrape category')
summary[componentType.name] = 0
}
}
return summary
}
/**
* Returns true if the given ComponentType's category has never been
* scraped, or was last scraped more than CACHE_TTL_HOURS ago.
*/
async function isCategoryStale(componentType: ComponentType): Promise<boolean> {
const mostRecent = await ScrapedPart.query()
.where('source', SOURCE)
.andWhere('typeId', componentType.id)
.orderBy('scrapedAt', 'desc')
.first()
if (!mostRecent) return true
return mostRecent.scrapedAt < DateTime.now().minus({ hours: CACHE_TTL_HOURS })
}
/**
* Ensures the cache for a given ComponentType's mapped category is fresh
* (scraped within the last CACHE_TTL_HOURS), re-scraping it synchronously
* if it's missing or stale. No-op for unmapped types. Scraping errors are
* logged but not thrown, so a search still falls back to whatever is
* already cached.
*
* Only fetches QUICK_REFRESH_MAX_PRODUCTS products (not the category's
* full configured depth), since this runs inline in a user's search
* request — run `node ace scrape:parts` for full-depth coverage.
*/
async function ensureFreshCategory(componentType: ComponentType): Promise<void> {
if (!CATEGORY_CONFIG[componentType.name]) return
if (!(await isCategoryStale(componentType))) return
try {
await scrapeComponentType(componentType, QUICK_REFRESH_MAX_PRODUCTS)
} catch (error) {
logger.error({ type: componentType.name, err: error }, 'Failed to refresh stale category cache')
}
}
/**
* Persists `offer` as a part's new price/source if it's cheaper than
* whatever is currently stored (or nothing was stored yet), so the "best
* known price" keeps improving as users compare prices (see
* ScrapedPartsController#compare) instead of resetting to a possibly
* stale/missing pc-builder.io price on the next catalog refresh —
* scrapeComponentType already refuses to overwrite a price with a worse
* one using this same "keep whichever is lower" rule.
*/
async function updatePriceIfCheaper(
part: ScrapedPart,
offer: { merchant: string; price: number; url: string }
): Promise<void> {
if (part.price !== null && part.price <= offer.price) return
part.price = offer.price
part.sourceUrl = offer.url
part.priceSource = offer.merchant
part.priceCheckedAt = DateTime.now()
await part.save()
}
/**
* Clears a part's price when a live comparison found no matching offer on
* any supported merchant (see ScrapedPartsController#compare), regardless
* of where the price previously came from (pc-builder.io catalog or an
* earlier verified merchant match). The pc-builder.io catalog price this
* part started with is frequently stale (see shouldKeepExistingPrice's
* doc for a confirmed example: pc-builder.io showed €128 for a CPU
* actually selling for €66.89) — once a user explicitly asks to verify a
* price and no merchant confirms it anymore, keeping the old number
* around is misleading, so it's dropped to null instead of silently
* falling back to "using catalog price €X".
*/
async function clearPriceIfNoOffers(part: ScrapedPart): Promise<void> {
if (part.price === null) return
part.price = null
part.priceSource = null
part.priceCheckedAt = DateTime.now()
await part.save()
}
export {
scrapeAllCategories,
scrapeComponentType,
ensureFreshCategory,
isCategoryStale,
updatePriceIfCheaper,
clearPriceIfNoOffers,
shouldKeepExistingPrice,
CATEGORY_CONFIG,
CATEGORY_SLUGS,
CACHE_TTL_HOURS,
QUICK_REFRESH_MAX_PRODUCTS,
}
+8 -2
View File
@@ -19,7 +19,10 @@ async function handlePictures(
await Promise.all(
pictures.map(async (picture: Image, index: number) => {
if ('id' in picture) {
const existingPicture = await ComputerPicture.findOrFail(picture.id)
const existingPicture = await ComputerPicture.query()
.where('computerId', computer.id)
.andWhere('id', picture.id)
.firstOrFail()
existingPicture.order = picture.order
await existingPicture.save()
} else {
@@ -44,7 +47,10 @@ async function handlePictures(
await Promise.all(
imagesToRemove.map(async (id: number) => {
const picture = await ComputerPicture.findOrFail(id)
const picture = await ComputerPicture.query()
.where('computerId', computer.id)
.andWhere('id', id)
.firstOrFail()
const filePath = `uploads/computer_${computer.id}/${picture.link.split('/').pop()}`
if (existsSync(filePath)) {
unlinkSync(filePath)
@@ -0,0 +1,30 @@
import type Computer from '#models/computer'
/**
* Transforms a Computer into the shape exposed on the public share page.
* Unlike the regular serialize(), this never leaks the owner id, and only
* includes sellPrice/cost when the owner explicitly enabled that on the
* share settings.
*/
export default class ComputerPublicTransformer {
static transform(computer: typeof Computer.prototype) {
return {
id: computer.id,
title: computer.title,
gpuScore: computer.gpuScore,
cpuScore: computer.cpuScore,
globalScore: computer.globalScore,
state: { name: computer.state.name },
sellPrice: computer.shareShowSellPrice ? computer.sellPrice : null,
cost: computer.shareShowCost ? computer.cost : null,
pictures: computer.pictures
.map((picture) => ({ link: picture.link, order: picture.order }))
.sort((a, b) => a.order - b.order),
components: computer.components.map((component) => ({
id: component.id,
name: component.name,
type: { name: component.type.name },
})),
}
}
}
-15
View File
@@ -1,15 +0,0 @@
import vine from '@vinejs/vine'
export const registerValidator = vine.compile(
vine.object({
name: vine.string().minLength(3).maxLength(64),
email: vine
.string()
.email()
.unique(async (query, field) => {
const user = await query.from('users').where('email', field).first()
return !user
}),
password: vine.string().minLength(8).maxLength(32),
})
)
+7
View File
@@ -6,6 +6,13 @@ export const storeComponentValidator = vine.compile(
price: vine.number().decimal([0, 2]).min(0),
typeId: vine.number().min(1),
purchaseDate: vine.string().regex(/^\d{4}-(0[1-9]|1[012])-(0[1-9]|[12][0-9]|3[01])$/),
purchaseUrl: vine
.string()
.trim()
.url({ require_protocol: true })
.maxLength(2048)
.nullable()
.optional(),
quantity: vine.number().min(1).withoutDecimals(),
})
)
+7
View File
@@ -6,5 +6,12 @@ export const updateComponentValidator = vine.compile(
price: vine.number().decimal([0, 2]).min(0),
typeId: vine.number().min(1),
purchaseDate: vine.string().regex(/^\d{4}-(0[1-9]|1[012])-(0[1-9]|[12][0-9]|3[01])$/),
purchaseUrl: vine
.string()
.trim()
.url({ require_protocol: true })
.maxLength(2048)
.nullable()
.optional(),
})
)
+9
View File
@@ -0,0 +1,9 @@
import vine from '@vinejs/vine'
export const updateShareSettingsValidator = vine.compile(
vine.object({
enabled: vine.boolean(),
showSellPrice: vine.boolean(),
showCost: vine.boolean(),
})
)
+35
View File
@@ -0,0 +1,35 @@
import { BaseCommand } from '@adonisjs/core/ace'
import type { CommandOptions } from '@adonisjs/core/types/ace'
export default class ScrapeParts extends BaseCommand {
static commandName = 'scrape:parts'
static description = 'Refresh the new-parts cache by scraping pc-builder.io category pages'
static options: CommandOptions = {
startApp: true,
}
async run() {
const { scrapeAllCategories } = await import('#services/part_scraper_service')
const { seedManualParts } = await import('#services/manual_part_seeder_service')
this.logger.info('Scraping pc-builder.io categories...')
const summary = await scrapeAllCategories()
let total = 0
for (const [type, count] of Object.entries(summary)) {
this.logger.info(` ${type}: ${count} parts`)
total += count
}
this.logger.success(
`Done. Upserted ${total} parts across ${Object.keys(summary).length} categories.`
)
this.logger.info('Seeding manually-tracked parts not yet in pc-builder.io catalog...')
const manualResult = await seedManualParts()
this.logger.success(
`Manual parts: ${manualResult.inserted} upserted, ${manualResult.skipped} skipped.`
)
}
}
+2 -1
View File
@@ -1,4 +1,5 @@
import env from '#start/env'
import app from '@adonisjs/core/services/app'
import { defineConfig, stores } from '@adonisjs/session'
const sessionConfig = defineConfig({
@@ -42,7 +43,7 @@ const sessionConfig = defineConfig({
/**
* Send cookies only over HTTPS in production.
*/
secure: false,
secure: app.inProduction,
/**
* Cross-site policy for cookie sending.
+41 -2
View File
@@ -1,4 +1,18 @@
import { defineConfig } from '@adonisjs/shield'
import app from '@adonisjs/core/services/app'
/**
* In development, Vite's dev server assets (scripts, styles, /@vite/client)
* are proxied through AdonisJS on the same origin, so 'self' already covers
* them — there is no working "@viteDevUrl"/"@viteHmrUrl" CSP keyword in this
* version of @adonisjs/shield (only "@viteUrl" exists, and it resolves to an
* empty string here since assetsUrl is a relative path, not an absolute
* dev-server URL). The one thing that truly lives on a different origin is
* the Vite HMR websocket, which runs on its own port (see VITE_HMR_PORT,
* default 24678) and therefore needs an explicit connect-src entry.
*/
const viteHmrPort = process.env.VITE_HMR_PORT ?? '24678'
const devConnectSrc = app.inDev ? [`ws://localhost:${viteHmrPort}`] : []
const shieldConfig = defineConfig({
/**
@@ -9,12 +23,37 @@ const shieldConfig = defineConfig({
/**
* Enable the Content-Security-Policy header.
*/
enabled: false,
enabled: true,
/**
* Per-resource CSP directives.
*/
directives: {},
directives: {
defaultSrc: [`'self'`],
connectSrc: [`'self'`, ...devConnectSrc],
scriptSrc: [`'self'`],
// Some client-side dependencies (e.g. Vite's HMR client / the Tuyau
// API client) spawn a Web Worker from a `blob:` URL built at
// runtime from already same-origin-loaded code. Without an explicit
// worker-src, browsers fall back to script-src ('self' only), which
// blocks the blob: worker outright — this was observed to break the
// dev HMR connection and leave the Vue app in an inconsistent state
// (lost TuyauProvider context after a failed hot reload).
workerSrc: [`'self'`, 'blob:'],
styleSrc: [`'self'`, `'unsafe-inline'`, 'https://fonts.googleapis.com'],
// The favicon service (used to show a merchant's icon in the price
// comparison UI) is requested from www.google.com but actually
// serves/redirects to Google's gstatic CDN (t0-t3.gstatic.com),
// hence the wildcard.
imgSrc: [
`'self'`,
'data:',
'https://fr.pc-builder.io',
'https://www.google.com',
'https://*.gstatic.com',
],
fontSrc: [`'self'`, 'data:', 'https://fonts.gstatic.com'],
},
/**
* Report violations without blocking resources.
@@ -0,0 +1,23 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
export default class extends BaseSchema {
protected tableName = 'computers'
async up() {
this.schema.alterTable(this.tableName, (table) => {
table.string('share_token', 64).nullable().unique()
table.boolean('share_enabled').notNullable().defaultTo(false)
table.boolean('share_show_sell_price').notNullable().defaultTo(false)
table.boolean('share_show_cost').notNullable().defaultTo(false)
})
}
async down() {
this.schema.alterTable(this.tableName, (table) => {
table.dropColumn('share_token')
table.dropColumn('share_enabled')
table.dropColumn('share_show_sell_price')
table.dropColumn('share_show_cost')
})
}
}
@@ -0,0 +1,28 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
export default class extends BaseSchema {
protected tableName = 'scraped_parts'
async up() {
this.schema.createTable(this.tableName, (table) => {
table.increments('id')
table.integer('type_id').unsigned().references('id').inTable('component_types').notNullable()
table.string('name').notNullable()
table.float('price', 8, 2).notNullable()
table.string('image_url').nullable()
table.string('source').notNullable().defaultTo('pc-builder.io')
table.string('source_url').notNullable()
table.string('source_ref').notNullable()
table.timestamp('scraped_at').notNullable()
table.timestamp('created_at')
table.timestamp('updated_at')
table.unique(['source', 'source_ref'])
table.index(['type_id'])
})
}
async down() {
this.schema.dropTable(this.tableName)
}
}
@@ -0,0 +1,17 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
export default class extends BaseSchema {
protected tableName = 'components'
async up() {
this.schema.alterTable(this.tableName, (table) => {
table.string('purchase_url').nullable()
})
}
async down() {
this.schema.alterTable(this.tableName, (table) => {
table.dropColumn('purchase_url')
})
}
}
@@ -0,0 +1,32 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
export default class extends BaseSchema {
protected tableName = 'scraped_parts'
async up() {
this.schema.alterTable(this.tableName, (table) => {
// Not every pc-builder.io listing has a fixed price (recent GPUs in
// particular often only have a generic "search on Amazon" link with
// no resolved offer) — these are now kept instead of being skipped,
// so price must be nullable.
table.float('price', 8, 2).nullable().alter()
// Tracks where the currently stored `price`/`source_url` came from:
// "pc-builder.io" for the original catalog price, or one of the
// merchant names from merchant_price_service.ts (Amazon, LDLC, ...)
// once a live price comparison finds (and persists) a cheaper offer.
// Null means no price has ever been resolved for this part.
table.string('price_source').nullable()
table.timestamp('price_checked_at').nullable()
})
}
async down() {
this.schema.alterTable(this.tableName, (table) => {
table.dropColumn('price_source')
table.dropColumn('price_checked_at')
})
this.schema.alterTable(this.tableName, (table) => {
table.float('price', 8, 2).notNullable().alter()
})
}
}
@@ -0,0 +1,27 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
export default class extends BaseSchema {
async up() {
// Some merchants (Grosbill in particular) return long tracking
// redirect URLs with encoded analytics query params well past 255
// characters — varchar(255) was truncating/rejecting inserts. Widen
// both scraped_parts.source_url (where these URLs are first stored)
// and components.purchase_url (where they end up when a scraped part
// gets imported into a Component — see components_controller.ts).
this.schema.alterTable('scraped_parts', (table) => {
table.text('source_url').notNullable().alter()
})
this.schema.alterTable('components', (table) => {
table.text('purchase_url').nullable().alter()
})
}
async down() {
this.schema.alterTable('scraped_parts', (table) => {
table.string('source_url').notNullable().alter()
})
this.schema.alterTable('components', (table) => {
table.string('purchase_url').nullable().alter()
})
}
}
+28 -362
View File
@@ -7,134 +7,6 @@
import { BaseModel, column } from '@adonisjs/lucid/orm'
import { DateTime } from 'luxon'
export class AccessListSchema extends BaseModel {
static $columns = ['createdOn', 'id', 'isDeleted', 'meta', 'modifiedOn', 'name', 'ownerUserId', 'passAuth', 'satisfyAny'] as const
$columns = AccessListSchema.$columns
@column.dateTime()
declare createdOn: DateTime
@column({ isPrimary: true })
declare id: number
@column()
declare isDeleted: number
@column()
declare meta: any
@column.dateTime()
declare modifiedOn: DateTime
@column()
declare name: string
@column()
declare ownerUserId: number
@column()
declare passAuth: number
@column()
declare satisfyAny: number
}
export class AccessListAuthSchema extends BaseModel {
static $columns = ['accessListId', 'createdOn', 'id', 'meta', 'modifiedOn', 'password', 'username'] as const
$columns = AccessListAuthSchema.$columns
@column()
declare accessListId: number
@column.dateTime()
declare createdOn: DateTime
@column({ isPrimary: true })
declare id: number
@column()
declare meta: any
@column.dateTime()
declare modifiedOn: DateTime
@column({ serializeAs: null })
declare password: string
@column()
declare username: string
}
export class AccessListClientSchema extends BaseModel {
static $columns = ['accessListId', 'address', 'createdOn', 'directive', 'id', 'meta', 'modifiedOn'] as const
$columns = AccessListClientSchema.$columns
@column()
declare accessListId: number
@column()
declare address: string
@column.dateTime()
declare createdOn: DateTime
@column()
declare directive: string
@column({ isPrimary: true })
declare id: number
@column()
declare meta: any
@column.dateTime()
declare modifiedOn: DateTime
}
export class AuditLogSchema extends BaseModel {
static $columns = ['action', 'createdOn', 'id', 'meta', 'modifiedOn', 'objectId', 'objectType', 'userId'] as const
$columns = AuditLogSchema.$columns
@column()
declare action: string
@column.dateTime()
declare createdOn: DateTime
@column({ isPrimary: true })
declare id: number
@column()
declare meta: any
@column.dateTime()
declare modifiedOn: DateTime
@column()
declare objectId: number
@column()
declare objectType: string
@column()
declare userId: number
}
export class AuthSchema extends BaseModel {
static $columns = ['createdOn', 'id', 'isDeleted', 'meta', 'modifiedOn', 'secret', 'type', 'userId'] as const
$columns = AuthSchema.$columns
@column.dateTime()
declare createdOn: DateTime
@column({ isPrimary: true })
declare id: number
@column()
declare isDeleted: number
@column()
declare meta: any
@column.dateTime()
declare modifiedOn: DateTime
@column()
declare secret: string
@column()
declare type: string
@column()
declare userId: number
}
export class CertificateSchema extends BaseModel {
static $columns = ['createdOn', 'domainNames', 'expiresOn', 'id', 'isDeleted', 'meta', 'modifiedOn', 'niceName', 'ownerUserId', 'provider'] as const
$columns = CertificateSchema.$columns
@column.dateTime()
declare createdOn: DateTime
@column()
declare domainNames: any
@column.dateTime()
declare expiresOn: DateTime
@column({ isPrimary: true })
declare id: number
@column()
declare isDeleted: number
@column()
declare meta: any
@column.dateTime()
declare modifiedOn: DateTime
@column()
declare niceName: string
@column()
declare ownerUserId: number
@column()
declare provider: string
}
export class ComponentTypeSchema extends BaseModel {
static $columns = ['id', 'name', 'unique'] as const
$columns = ComponentTypeSchema.$columns
@@ -147,7 +19,7 @@ export class ComponentTypeSchema extends BaseModel {
}
export class ComponentSchema extends BaseModel {
static $columns = ['computerId', 'createdAt', 'id', 'name', 'price', 'purchaseDate', 'typeId', 'updatedAt', 'userId'] as const
static $columns = ['computerId', 'createdAt', 'id', 'name', 'price', 'purchaseDate', 'purchaseUrl', 'typeId', 'updatedAt', 'userId'] as const
$columns = ComponentSchema.$columns
@column()
declare computerId: number | null
@@ -162,6 +34,8 @@ export class ComponentSchema extends BaseModel {
@column.date()
declare purchaseDate: DateTime
@column()
declare purchaseUrl: string | null
@column()
declare typeId: number | null
@column.dateTime({ autoCreate: true, autoUpdate: true })
declare updatedAt: DateTime | null
@@ -196,7 +70,7 @@ export class ComputerStateSchema extends BaseModel {
}
export class ComputerSchema extends BaseModel {
static $columns = ['cpuScore', 'createdAt', 'globalScore', 'gpuScore', 'id', 'sellPrice', 'stateId', 'title', 'updatedAt', 'userId'] as const
static $columns = ['cpuScore', 'createdAt', 'globalScore', 'gpuScore', 'id', 'sellPrice', 'shareEnabled', 'shareShowCost', 'shareShowSellPrice', 'shareToken', 'stateId', 'title', 'updatedAt', 'userId'] as const
$columns = ComputerSchema.$columns
@column()
declare cpuScore: number
@@ -211,6 +85,14 @@ export class ComputerSchema extends BaseModel {
@column()
declare sellPrice: number | null
@column()
declare shareEnabled: boolean
@column()
declare shareShowCost: boolean
@column()
declare shareShowSellPrice: boolean
@column()
declare shareToken: string | null
@column()
declare stateId: number
@column()
declare title: string
@@ -220,251 +102,35 @@ export class ComputerSchema extends BaseModel {
declare userId: number | null
}
export class DeadHostSchema extends BaseModel {
static $columns = ['advancedConfig', 'certificateId', 'createdOn', 'domainNames', 'enabled', 'hstsEnabled', 'hstsSubdomains', 'http2Support', 'id', 'isDeleted', 'meta', 'modifiedOn', 'ownerUserId', 'sslForced'] as const
$columns = DeadHostSchema.$columns
@column()
declare advancedConfig: string
@column()
declare certificateId: number
@column.dateTime()
declare createdOn: DateTime
@column()
declare domainNames: any
@column()
declare enabled: number
@column()
declare hstsEnabled: number
@column()
declare hstsSubdomains: number
@column()
declare http2Support: number
export class ScrapedPartSchema extends BaseModel {
static $columns = ['createdAt', 'id', 'imageUrl', 'name', 'price', 'priceCheckedAt', 'priceSource', 'scrapedAt', 'source', 'sourceRef', 'sourceUrl', 'typeId', 'updatedAt'] as const
$columns = ScrapedPartSchema.$columns
@column.dateTime({ autoCreate: true })
declare createdAt: DateTime | null
@column({ isPrimary: true })
declare id: number
@column()
declare isDeleted: number
@column()
declare meta: any
@column.dateTime()
declare modifiedOn: DateTime
@column()
declare ownerUserId: number
@column()
declare sslForced: number
}
export class MigrationSchema extends BaseModel {
static $columns = ['batch', 'id', 'migrationTime', 'name'] as const
$columns = MigrationSchema.$columns
@column()
declare batch: number | null
@column({ isPrimary: true })
declare id: number
@column.dateTime()
declare migrationTime: DateTime | null
@column()
declare name: string | null
}
export class MigrationsLockSchema extends BaseModel {
static $columns = ['index', 'isLocked'] as const
$columns = MigrationsLockSchema.$columns
@column({ isPrimary: true })
declare index: number
@column()
declare isLocked: number | null
}
export class ProxyHostSchema extends BaseModel {
static $columns = ['accessListId', 'advancedConfig', 'allowWebsocketUpgrade', 'blockExploits', 'cachingEnabled', 'certificateId', 'createdOn', 'domainNames', 'enabled', 'forwardHost', 'forwardPort', 'forwardScheme', 'hstsEnabled', 'hstsSubdomains', 'http2Support', 'id', 'isDeleted', 'locations', 'meta', 'modifiedOn', 'ownerUserId', 'sslForced', 'trustForwardedProto'] as const
$columns = ProxyHostSchema.$columns
@column()
declare accessListId: number
@column()
declare advancedConfig: string
@column()
declare allowWebsocketUpgrade: number
@column()
declare blockExploits: number
@column()
declare cachingEnabled: number
@column()
declare certificateId: number
@column.dateTime()
declare createdOn: DateTime
@column()
declare domainNames: any
@column()
declare enabled: number
@column()
declare forwardHost: string
@column()
declare forwardPort: number
@column()
declare forwardScheme: string
@column()
declare hstsEnabled: number
@column()
declare hstsSubdomains: number
@column()
declare http2Support: number
@column({ isPrimary: true })
declare id: number
@column()
declare isDeleted: number
@column()
declare locations: any | null
@column()
declare meta: any
@column.dateTime()
declare modifiedOn: DateTime
@column()
declare ownerUserId: number
@column()
declare sslForced: number
@column()
declare trustForwardedProto: boolean
}
export class RedirectionHostSchema extends BaseModel {
static $columns = ['advancedConfig', 'blockExploits', 'certificateId', 'createdOn', 'domainNames', 'enabled', 'forwardDomainName', 'forwardHttpCode', 'forwardScheme', 'hstsEnabled', 'hstsSubdomains', 'http2Support', 'id', 'isDeleted', 'meta', 'modifiedOn', 'ownerUserId', 'preservePath', 'sslForced'] as const
$columns = RedirectionHostSchema.$columns
@column()
declare advancedConfig: string
@column()
declare blockExploits: number
@column()
declare certificateId: number
@column.dateTime()
declare createdOn: DateTime
@column()
declare domainNames: any
@column()
declare enabled: number
@column()
declare forwardDomainName: string
@column()
declare forwardHttpCode: number
@column()
declare forwardScheme: string
@column()
declare hstsEnabled: number
@column()
declare hstsSubdomains: number
@column()
declare http2Support: number
@column({ isPrimary: true })
declare id: number
@column()
declare isDeleted: number
@column()
declare meta: any
@column.dateTime()
declare modifiedOn: DateTime
@column()
declare ownerUserId: number
@column()
declare preservePath: number
@column()
declare sslForced: number
}
export class SettingSchema extends BaseModel {
static $columns = ['description', 'id', 'meta', 'name', 'value'] as const
$columns = SettingSchema.$columns
@column()
declare description: string
@column({ isPrimary: true })
declare id: string
@column()
declare meta: any
declare imageUrl: string | null
@column()
declare name: string
@column()
declare value: string
}
export class StreamSchema extends BaseModel {
static $columns = ['certificateId', 'createdOn', 'enabled', 'forwardingHost', 'forwardingPort', 'id', 'incomingPort', 'isDeleted', 'meta', 'modifiedOn', 'ownerUserId', 'tcpForwarding', 'udpForwarding'] as const
$columns = StreamSchema.$columns
@column()
declare certificateId: number
declare price: number | null
@column.dateTime()
declare createdOn: DateTime
declare priceCheckedAt: DateTime | null
@column()
declare enabled: number
@column()
declare forwardingHost: string
@column()
declare forwardingPort: number
@column({ isPrimary: true })
declare id: number
@column()
declare incomingPort: number
@column()
declare isDeleted: number
@column()
declare meta: any
declare priceSource: string | null
@column.dateTime()
declare modifiedOn: DateTime
declare scrapedAt: DateTime
@column()
declare ownerUserId: number
declare source: string
@column()
declare tcpForwarding: number
declare sourceRef: string
@column()
declare udpForwarding: number
}
export class NpmUserSchema extends BaseModel {
static $columns = ['avatar', 'createdOn', 'email', 'id', 'isDeleted', 'isDisabled', 'modifiedOn', 'name', 'nickname', 'roles'] as const
$columns = NpmUserSchema.$columns
declare sourceUrl: string
@column()
declare avatar: string
@column.dateTime()
declare createdOn: DateTime
@column()
declare email: string
@column({ isPrimary: true })
declare id: number
@column()
declare isDeleted: number
@column()
declare isDisabled: number
@column.dateTime()
declare modifiedOn: DateTime
@column()
declare name: string
@column()
declare nickname: string
@column()
declare roles: any
}
export class UserPermissionSchema extends BaseModel {
static $columns = ['accessLists', 'certificates', 'createdOn', 'deadHosts', 'id', 'modifiedOn', 'proxyHosts', 'redirectionHosts', 'streams', 'userId', 'visibility'] as const
$columns = UserPermissionSchema.$columns
@column()
declare accessLists: string
@column()
declare certificates: string
@column.dateTime()
declare createdOn: DateTime
@column()
declare deadHosts: string
@column({ isPrimary: true })
declare id: number
@column.dateTime()
declare modifiedOn: DateTime
@column()
declare proxyHosts: string
@column()
declare redirectionHosts: string
@column()
declare streams: string
@column()
declare userId: number
@column()
declare visibility: string
declare typeId: number
@column.dateTime({ autoCreate: true, autoUpdate: true })
declare updatedAt: DateTime | null
}
export class UserSchema extends BaseModel {
File renamed without changes.
File renamed without changes.
File renamed without changes.
+2 -2
View File
@@ -1,9 +1,9 @@
import { ref, watch } from 'vue'
// Cr├®er une r├®f├®rence pour isAdminActive
// Créer une référence pour isAdminActive
const isAdminActive = ref(JSON.parse(localStorage.getItem('isAdminActive') || 'false'))
// Surveiller les changements de isAdminActive et mettre à jour localStorage
// Surveiller les changements de isAdminActive et mettre à jour localStorage
watch(isAdminActive, (newValue) => {
localStorage.setItem('isAdminActive', JSON.stringify(newValue))
})
+5 -5
View File
@@ -1,7 +1,7 @@
import { createApp, DirectiveBinding, h, ObjectDirective } from 'vue'
import DeleteConfirmationModal from '~/pages/widgets/DeleteConfirmationModal.vue'
// Interface pour ├®tendre HTMLElement avec _handleClick
// Interface pour étendre HTMLElement avec _handleClick
interface HTMLElementWithHandleClick extends HTMLElement {
_handleClick?: (event: Event) => void
}
@@ -9,8 +9,8 @@ interface HTMLElementWithHandleClick extends HTMLElement {
const deleteConfirmDirective: ObjectDirective<HTMLElementWithHandleClick> = {
mounted(el: HTMLElementWithHandleClick, binding: DirectiveBinding) {
const handleClick = (event: Event) => {
event.stopPropagation() // Emp├¬cher la propagation de l'├®v├®nement
event.preventDefault() // Emp├¬cher l'action par d├®faut
event.stopPropagation() // Empêcher la propagation de l'événement
event.preventDefault() // Empêcher l'action par défaut
const modalApp = createApp({
data() {
@@ -20,7 +20,7 @@ const deleteConfirmDirective: ObjectDirective<HTMLElementWithHandleClick> = {
confirm() {
this.show = false
document.body.removeChild(modalContainer)
binding.value() // Ex├®cuter la fonction de suppression
binding.value() // Exécuter la fonction de suppression
},
cancel() {
this.show = false
@@ -42,7 +42,7 @@ const deleteConfirmDirective: ObjectDirective<HTMLElementWithHandleClick> = {
}
el.addEventListener('click', handleClick)
el._handleClick = handleClick // Stocker la r├®f├®rence pour la suppression ult├®rieure
el._handleClick = handleClick // Stocker la référence pour la suppression ultérieure
},
unmounted(el: HTMLElementWithHandleClick) {
if (el._handleClick) {
+3 -3
View File
@@ -6,7 +6,7 @@ const formatPrice = (value: string): string => {
return Number.isNaN(numberValue) ? '' : numberValue.toFixed(2).replace('.', ',')
}
// Interface pour ├®tendre HTMLInputElement avec _handleBlur
// Interface pour étendre HTMLInputElement avec _handleBlur
interface HTMLInputElementWithHandleBlur extends HTMLInputElement {
_handleBlur?: () => void
}
@@ -15,10 +15,10 @@ const priceDirective: ObjectDirective<HTMLInputElementWithHandleBlur> = {
mounted(el: HTMLInputElementWithHandleBlur) {
const handleBlur = () => {
el.value = formatPrice(el.value)
el.dispatchEvent(new Event('input')) // Pour mettre à jour le v-model
el.dispatchEvent(new Event('input')) // Pour mettre à jour le v-model
}
el.addEventListener('blur', handleBlur)
el._handleBlur = handleBlur // Stocker la r├®f├®rence pour la suppression ult├®rieure
el._handleBlur = handleBlur // Stocker la référence pour la suppression ultérieure
},
unmounted(el: HTMLInputElementWithHandleBlur) {
if (el._handleBlur) {
+3 -3
View File
@@ -33,7 +33,7 @@ const validationErrorDirective: ObjectDirective<HTMLElement, string | null> = {
const vnode = createVNode(ValidationError, { message: errorMessage })
render(vnode, container)
// Force reflow pour d├®clencher la transition
// Force reflow pour déclencher la transition
void container.offsetWidth
container.classList.remove('w-0', 'opacity-0')
container.classList.add('w-full', 'opacity-100')
@@ -77,7 +77,7 @@ const validationErrorDirective: ObjectDirective<HTMLElement, string | null> = {
const vnode = createVNode(ValidationError, { message: errorMessage })
render(vnode, newContainer)
// Force reflow pour d├®clencher la transition
// Force reflow pour déclencher la transition
void newContainer.offsetWidth
newContainer.classList.remove('w-0', 'opacity-0')
newContainer.classList.add('w-100', 'opacity-100')
@@ -92,7 +92,7 @@ const validationErrorDirective: ObjectDirective<HTMLElement, string | null> = {
container.classList.remove('transition-all', 'duration-2500', 'ease-in-out') // Supprimez les classes de transition pour la disparition
container.classList.add('w-0', 'opacity-0')
// Supprimez le conteneur imm├®diatement sans d├®lai
// Supprimez le conteneur immédiatement sans délai
render(null, container)
container.remove()
;(el as any)._validationErrorContainer = null
+35 -7
View File
@@ -3,16 +3,32 @@ import { reactive } from 'vue'
import { Link } from '@adonisjs/inertia/vue'
import { useForm } from '@inertiajs/vue3'
import CustomSelect from '~/pages/widgets/CustomSelect.vue'
import NewPartSearch from '~/pages/widgets/NewPartSearch.vue'
import type { ComponentType, MerchantOffer, ScrapedPart } from '~/pages/types/UITypes'
defineProps<{ types: any[]; errors?: Record<string, string> }>()
defineProps<{ types: ComponentType[]; errors?: Record<string, string> }>()
const form = reactive({
name: '',
price: 0,
typeId: null,
typeId: null as number | null,
purchaseDate: new Date().toISOString().split('T')[0],
purchaseUrl: '',
quantity: 1,
})
function importPart(part: ScrapedPart, cheapestOffer: MerchantOffer | null) {
form.name = part.name
// Prefer the live price/link found by comparing merchants (Amazon, LDLC,
// Grosbill, TopAchat, ...) when available, since pc-builder.io's catalog
// price can be stale (or simply unknown — see ScrapedPart.price doc);
// fall back to the catalog price/link, or leave the price field as-is
// for the user to fill in manually if neither is known.
form.price = cheapestOffer?.price ?? part.price ?? form.price
form.typeId = part.type.id
form.purchaseDate = new Date().toISOString().split('T')[0]
form.purchaseUrl = cheapestOffer?.url ?? part.sourceUrl
}
function submitForm() {
const inertiaForm = useForm(form)
inertiaForm.post('/components')
@@ -22,6 +38,9 @@ function submitForm() {
<template>
<div class="max-w-2xl mx-auto p-8 bg-white shadow-lg rounded-lg">
<h1 class="text-2xl font-semibold mb-8 text-gray-800">Create New Component</h1>
<NewPartSearch class="mb-8" @import="importPart" />
<form @submit.prevent="submitForm" class="space-y-6">
<div>
<label class="block text-gray-700 text-sm font-bold mb-2" for="name">Name</label>
@@ -47,11 +66,7 @@ function submitForm() {
<div>
<label class="block text-gray-700 text-sm font-bold mb-2" for="type">Type</label>
<CustomSelect
v-model="form.typeId"
:options="types"
placeholder="Choose a type"
/>
<CustomSelect v-model="form.typeId" :options="types" placeholder="Choose a type" />
</div>
<div>
@@ -66,6 +81,19 @@ function submitForm() {
/>
</div>
<div>
<label class="block text-gray-700 text-sm font-bold mb-2" for="purchaseUrl"
>Purchase Site (optional, filled automatically when importing a new part)</label
>
<input
name="purchaseUrl"
v-model="form.purchaseUrl"
class="w-full mt-2 border-gray-200 focus:border-indigo-600 focus:ring focus:ring-indigo-500/40 px-3 py-2 border rounded text-gray-700"
type="url"
placeholder="https://..."
/>
</div>
<div>
<label class="block text-gray-700 text-sm font-bold mb-2" for="quantity">Quantity</label>
<input
+19 -10
View File
@@ -3,14 +3,14 @@ import { reactive } from 'vue'
import { Link } from '@adonisjs/inertia/vue'
import { usePage } from '@inertiajs/vue3'
import CustomSelect from '~/pages/widgets/CustomSelect.vue'
import type { Component, ComponentType } from '~/pages/types/UITypes'
const { props } = usePage<{
component: any
types: any[]
origin: string
component: Component
types: ComponentType[]
errors?: Record<string, string>
}>()
const { component, types, origin } = props
const { component, types } = props
const form = reactive({
id: component.id,
@@ -18,7 +18,7 @@ const form = reactive({
price: component.price,
typeId: component.type.id,
purchaseDate: component.purchaseDate,
origin: origin,
purchaseUrl: component.purchaseUrl ?? '',
})
</script>
@@ -49,11 +49,7 @@ const form = reactive({
<div>
<label class="block text-gray-700 text-sm font-bold mb-2" for="type">Type</label>
<CustomSelect
v-model="form.typeId"
:options="types"
placeholder="Choose a type"
/>
<CustomSelect v-model="form.typeId" :options="types" placeholder="Choose a type" />
</div>
<div>
@@ -68,6 +64,19 @@ const form = reactive({
/>
</div>
<div>
<label class="block text-gray-700 text-sm font-bold mb-2" for="purchaseUrl"
>Purchase Site (optional)</label
>
<input
name="purchaseUrl"
v-model="form.purchaseUrl"
class="w-full mt-2 border-gray-200 focus:border-indigo-600 focus:ring focus:ring-indigo-500/40 px-3 py-2 border rounded text-gray-700"
type="url"
placeholder="https://..."
/>
</div>
<Link
:href="`/components/${form.id}`"
method="put"
+14 -1
View File
@@ -1,7 +1,8 @@
<script setup lang="ts">
import { Link } from '@adonisjs/inertia/vue'
import type { Component } from '~/pages/types/UITypes'
const props = defineProps<{ component: any }>()
const props = defineProps<{ component: Component }>()
</script>
<template>
@@ -35,6 +36,18 @@ const props = defineProps<{ component: any }>()
</div>
</div>
<div v-if="props.component.purchaseUrl" class="mb-6">
<label class="block text-gray-700 text-sm font-bold mb-2">Purchase Site</label>
<a
:href="props.component.purchaseUrl"
target="_blank"
rel="noopener noreferrer"
class="block px-3 py-2 border rounded text-indigo-600 hover:text-indigo-800 hover:underline truncate"
>
{{ props.component.purchaseUrl }}
</a>
</div>
<Link
:href="`/components/${props.component.id}/edit`"
type="button"
+38 -18
View File
@@ -1,11 +1,12 @@
<script setup lang="ts">
import { computed, reactive } from 'vue'
import { computed } from 'vue'
import { Link } from '@adonisjs/inertia/vue'
import { usePage } from '@inertiajs/vue3'
import { useForm, usePage } from '@inertiajs/vue3'
import type { Component, ComputerState } from '~/pages/types/UITypes'
const { props } = usePage<{
components: any[]
states: any[]
components: Component[]
states: ComputerState[]
errors: Record<string, string>
}>()
const { components, states } = props
@@ -26,7 +27,7 @@ const componentTypeOrder = [
]
const componentsByType = computed(() => {
const groups = new Map<string, any[]>()
const groups = new Map<string, Component[]>()
components.forEach((component) => {
if (!groups.has(component.type.name)) {
groups.set(component.type.name, [])
@@ -38,21 +39,35 @@ const componentsByType = computed(() => {
.sort((a, b) => componentTypeOrder.indexOf(a.type) - componentTypeOrder.indexOf(b.type))
})
const form = reactive({
const form = useForm<{
title: string
sellPrice: number
gpuScore: number
cpuScore: number
stateId: number
globalScore: number
componentsId: number[]
pictures: Record<string, never>[]
}>({
title: '',
sellPrice: 0,
gpuScore: 0,
cpuScore: 0,
stateId: 1,
globalScore: 0,
componentsId: [] as number[],
componentsId: [],
pictures: [],
})
function submitForm() {
form.post('/computers')
}
</script>
<template>
<div class="max-w-4xl mx-auto p-8 bg-white shadow-lg rounded-lg">
<h1 class="text-2xl font-semibold mb-8 text-gray-800">Create New Computer</h1>
<form class="space-y-6">
<form class="space-y-6" @submit.prevent="submitForm">
<div class="mt-2">
<label class="block text-gray-700 text-sm font-bold mb-2" for="title">Title</label>
<input
@@ -149,16 +164,21 @@ const form = reactive({
</div>
</div>
<Link
href="/computers"
type="button"
as="button"
method="post"
:data="form"
class="px-4 py-2 bg-indigo-600 text-white rounded hover:bg-blue-700 transition"
>
Create
</Link>
<div class="flex gap-4">
<button
type="submit"
:disabled="form.processing"
class="px-4 py-2 bg-indigo-600 text-white rounded hover:bg-indigo-700 transition disabled:opacity-50"
>
{{ form.processing ? 'Creating…' : 'Create' }}
</button>
<Link
href="/computers"
class="px-4 py-2 bg-gray-300 text-gray-800 rounded hover:bg-gray-400 transition"
>
Cancel
</Link>
</div>
</form>
</div>
</template>
+5 -4
View File
@@ -2,11 +2,12 @@
import { reactive } from 'vue'
import { Link } from '@adonisjs/inertia/vue'
import { router, usePage } from '@inertiajs/vue3'
import type { Component, Computer, ComputerState } from '~/pages/types/UITypes'
const { props } = usePage<{
computer: any
availableComponents: any[]
states: any[]
computer: Computer
availableComponents: Component[]
states: ComputerState[]
errors?: Record<string, string>
}>()
const { computer, states } = props
@@ -19,7 +20,7 @@ const form = reactive({
cpuScore: computer.cpuScore,
globalScore: computer.globalScore,
stateId: computer.state.id,
componentsId: computer.components.map((c: any) => c.id),
componentsId: computer.components.map((c) => c.id),
})
function deleteComputer(id: number) {
+2 -83
View File
@@ -1,7 +1,8 @@
<script setup lang="ts">
import { Link } from '@adonisjs/inertia/vue'
import type { Computer } from '~/pages/types/UITypes'
defineProps<{ computers: any[] }>()
defineProps<{ computers: Computer[] }>()
</script>
<template>
@@ -44,85 +45,3 @@ defineProps<{ computers: any[] }>()
</div>
</div>
</template>
<style scoped>
.computers-container {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
}
.computers-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 2rem;
}
.computer-card {
border: 1px solid #e5e7eb;
border-radius: 0.5rem;
overflow: hidden;
display: flex;
flex-direction: column;
}
.card-image {
width: 100%;
height: 200px;
background-color: #f3f4f6;
display: flex;
align-items: center;
justify-content: center;
}
.card-image img {
width: 100%;
height: 100%;
object-fit: cover;
}
.no-image {
display: flex;
align-items: center;
justify-content: center;
color: #9ca3af;
}
.card-content {
padding: 1rem;
flex: 1;
display: flex;
flex-direction: column;
}
.card-content h3 {
margin: 0 0 0.5rem 0;
font-size: 1.125rem;
}
.card-content p {
color: #6b7280;
margin: 0 0 1rem 0;
flex: 1;
}
.card-meta {
display: flex;
justify-content: space-between;
margin-bottom: 1rem;
font-size: 0.875rem;
color: #6b7280;
}
.empty-state {
text-align: center;
padding: 4rem 2rem;
}
</style>
+24 -4
View File
@@ -1,14 +1,34 @@
<script setup lang="ts">
import { ref } from 'vue'
import { Link } from '@adonisjs/inertia/vue'
import type { Computer } from '~/pages/types/UITypes'
import ComputerShareModal from '~/pages/widgets/ComputerShareModal.vue'
const { computer } = defineProps<{ computer: any }>()
const { computer } = defineProps<{ computer: Computer }>()
const showShareModal = ref(false)
</script>
<template>
<div class="max-w-4xl mx-auto p-4 md:p-8 bg-white shadow-lg rounded-lg">
<h1 class="text-xl md:text-2xl font-semibold mb-4 md:mb-8 text-gray-800">
{{ computer.title }}
</h1>
<div class="flex items-center justify-between mb-4 md:mb-8">
<h1 class="text-xl md:text-2xl font-semibold text-gray-800">
{{ computer.title }}
</h1>
<button
type="button"
@click="showShareModal = true"
class="px-4 py-2 bg-gray-200 text-gray-800 rounded hover:bg-gray-300 transition text-sm shrink-0"
>
{{ computer.shareEnabled ? 'Shared' : 'Share' }}
</button>
</div>
<ComputerShareModal
:show="showShareModal"
:computer="computer"
@close="showShareModal = false"
/>
<div class="mt-2">
<label class="block text-gray-700 text-sm font-bold mb-2">Components</label>
+1 -25
View File
@@ -16,33 +16,9 @@ const user = computed(() => {
<template>
<header
class="flex justify-between items-center px-10 h-16 bg-white ml-64 w-[calc(100%-16rem)] fixed top-0 right-0 z-40 border-b border-outline-variant shadow-sm"
class="flex justify-end items-center px-10 h-16 bg-white ml-64 w-[calc(100%-16rem)] fixed top-0 right-0 z-40 border-b border-outline-variant shadow-sm"
>
<div
class="flex items-center gap-4 bg-surface-container-low px-4 py-2 rounded-lg border border-outline-variant w-96 focus-within:ring-2 focus-within:ring-primary transition-all"
>
<span class="material-symbols-outlined text-on-surface-variant">search</span>
<input
class="bg-transparent border-none focus:ring-0 focus:outline-none text-body-sm font-body-sm w-full p-0"
placeholder="Search builds..."
type="text"
/>
</div>
<div class="flex items-center gap-6">
<button
class="text-on-surface-variant hover:bg-surface-container-low p-2 rounded-full transition-all"
>
<span class="material-symbols-outlined">notifications</span>
</button>
<button
class="text-on-surface-variant hover:bg-surface-container-low p-2 rounded-full transition-all"
>
<span class="material-symbols-outlined">settings</span>
</button>
<div class="h-8 w-px bg-outline-variant mx-2"></div>
<div
class="relative flex items-center gap-3 cursor-pointer group"
@click="dropdownOpen = !dropdownOpen"
+1 -3
View File
@@ -1,5 +1,5 @@
<script lang="ts" setup>
import { ref, useAttrs } from 'vue'
import { ref } from 'vue'
import { useSidebar } from '~/composables/useSidebar'
import { Link } from '@adonisjs/inertia/vue'
import { siteIcon } from '~/assets/icons'
@@ -9,8 +9,6 @@ const activeClass = ref('bg-gray-600/25 text-gray-100 border-gray-100')
const inactiveClass = ref(
'border-gray-900 text-gray-500 hover:bg-gray-600/25 hover:text-gray-100'
)
const route = useAttrs()
console.log(route)
</script>
<template>
+92
View File
@@ -0,0 +1,92 @@
<script setup lang="ts">
import EmptyLayout from '~/pages/layout/emptyLayout.vue'
import { noImagePlaceholder } from '~/assets/images'
import type { PublicComputer } from '~/pages/types/UITypes'
defineOptions({ layout: EmptyLayout })
const { computer } = defineProps<{ computer: PublicComputer }>()
</script>
<template>
<div class="min-h-screen bg-gradient-to-br from-gray-100 to-gray-200 py-8 px-4">
<div class="max-w-3xl mx-auto p-4 md:p-8 bg-white shadow-lg rounded-lg">
<p class="text-xs uppercase tracking-wide text-indigo-600 font-semibold mb-2">Shared build</p>
<h1 class="text-xl md:text-2xl font-semibold mb-6 text-gray-800">
{{ computer.title }}
</h1>
<img
class="w-full max-h-96 object-cover rounded-lg mb-6"
:src="computer.pictures[0]?.link || noImagePlaceholder"
alt="Computer picture"
/>
<div class="mt-2">
<label class="block text-gray-700 text-sm font-bold mb-2">Components</label>
<div class="overflow-x-auto">
<table class="w-full border-collapse">
<thead class="bg-gray-200">
<tr>
<th class="border px-4 py-2 text-left">Name</th>
<th class="border px-4 py-2 text-left">Type</th>
</tr>
</thead>
<tbody>
<tr
v-for="component in computer.components"
:key="component.id"
class="hover:bg-gray-50"
>
<td class="border px-4 py-2">{{ component.name }}</td>
<td class="border px-4 py-2">{{ component.type?.name }}</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="mt-6 flex flex-wrap gap-4">
<div class="flex-1 min-w-[100px]">
<label class="block text-gray-700 text-sm font-bold mb-2">GPU Score</label>
<p class="w-full border-gray-200 px-3 py-2 border rounded text-gray-700">
{{ computer.gpuScore }}
</p>
</div>
<div class="flex-1 min-w-[100px]">
<label class="block text-gray-700 text-sm font-bold mb-2">CPU Score</label>
<p class="w-full border-gray-200 px-3 py-2 border rounded text-gray-700">
{{ computer.cpuScore }}
</p>
</div>
<div class="flex-1 min-w-[100px]">
<label class="block text-gray-700 text-sm font-bold mb-2">Global Score</label>
<p class="w-full border-gray-200 px-3 py-2 border rounded text-gray-700">
{{ computer.globalScore }}
</p>
</div>
</div>
<div class="mt-4 grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="pt-2">
<label class="block text-gray-700 text-sm font-bold mb-2">State</label>
<p class="w-full mt-2 border-gray-200 px-3 py-2.5 border rounded text-gray-700">
{{ computer.state.name }}
</p>
</div>
<div v-if="computer.sellPrice !== null" class="mt-2">
<label class="block text-gray-700 text-sm font-bold mb-2">Sell Price</label>
<p class="w-full mt-2 border-gray-200 px-3 py-2 border rounded text-gray-700">
${{ computer.sellPrice.toFixed(2) }}
</p>
</div>
<div v-if="computer.cost !== null" class="mt-2">
<label class="block text-gray-700 text-sm font-bold mb-2">Cost</label>
<p class="w-full mt-2 border-gray-200 px-3 py-2 border rounded text-gray-700">
${{ computer.cost.toFixed(2) }}
</p>
</div>
</div>
</div>
</div>
</template>
+46 -5
View File
@@ -1,10 +1,10 @@
interface ComponentType {
export type ComponentType = {
id: number
name: string
unique: boolean
}
interface ComputerState {
export type ComputerState = {
id: number
name: string
}
@@ -20,22 +20,63 @@ export type Computer = {
cost: number
pictures: { link: string; order: number }[]
components: Component[]
shareEnabled: boolean
shareShowSellPrice: boolean
shareShowCost: boolean
shareToken?: string | null
}
export type PublicComputer = {
id: number
title: string
gpuScore: number
cpuScore: number
globalScore: number
state: { name: string }
sellPrice: number | null
cost: number | null
pictures: { link: string; order: number }[]
components: { id: number; name: string; type: { name: string } }[]
}
export type Component = {
id: number
name: string
price: string
componentTypeId: number
computerId: number | null
userId: number
componentStateId: number
purchaseDate: string
purchaseUrl: string | null
createdAt: string
updatedAt: string
type: ComponentType
}
export type ScrapedPart = {
id: number
name: string
// Null when pc-builder.io has no fixed offer for this part yet (common
// for very recent products). Comparing prices (see NewPartSearch.vue)
// can resolve and persist a real price from another merchant.
price: number | null
imageUrl: string | null
source: string
sourceUrl: string
type: ComponentType
}
export type MerchantOffer = {
merchant:
| 'Amazon'
| 'LDLC'
| 'Grosbill'
| 'TopAchat'
| 'Materiel.net'
| 'Cdiscount'
| 'PCComponentes'
price: number
url: string
}
export interface ServerImage {
id: number
link: string
+2 -2
View File
@@ -30,7 +30,7 @@ const componentTypeOrder = [
'Other',
]
// Fusionner les composants disponibles et initialement s├®lectionn├®s
// Fusionner les composants disponibles et initialement sélectionnés
const allComponents = computed(() => {
const mergedComponents = [...props.availableComponents]
if (props.initialSelectedComponents) {
@@ -134,7 +134,7 @@ function updateComponents(event: Event, type: string) {
</select>
</div>
<div v-else>
<!-- Utilisation de cases à cocher pour les composants non uniques avec grille responsive -->
<!-- Utilisation de cases à cocher pour les composants non uniques avec grille responsive -->
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
<div v-for="component in components" :key="component.id" class="flex items-center mt-1">
<input
+27 -140
View File
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed, reactive, ref } from 'vue'
import { computed, ref } from 'vue'
import TableComponent from '~/pages/widgets/TableComponent.vue'
import FilterDropdown from '~/pages/widgets/FilterDropdown.vue'
import type Component from '#models/component'
import type ComponentType from '#models/component_type'
import { usePage } from '@inertiajs/vue3'
@@ -8,19 +9,6 @@ import { usePage } from '@inertiajs/vue3'
const { props } = usePage<{ components: Component[] }>()
const { components } = props
const usageDropdownOpen = ref(false)
const typeDropdownOpen = ref(false)
function toggleDropdown(dropdown: any) {
if (dropdown === 'usage') {
usageDropdownOpen.value = !usageDropdownOpen.value
typeDropdownOpen.value = false
} else if (dropdown === 'type') {
typeDropdownOpen.value = !typeDropdownOpen.value
usageDropdownOpen.value = false
}
}
const componentTypes = computed(() => {
const types = new Map<number, ComponentType>()
components.forEach((component) => {
@@ -31,152 +19,51 @@ const componentTypes = computed(() => {
return Array.from(types.values())
})
// D├®finir le type de l'objet filters
interface Filters {
showUsed: boolean
showUnused: boolean
types: { [key: number]: boolean }
}
const typeOptions = computed(() =>
componentTypes.value.map((type) => ({ id: type.id, label: type.name }))
)
const filters = reactive<Filters>({
showUsed: false,
showUnused: true,
types: componentTypes.value.reduce(
const usageFilters = ref<Record<string, boolean>>({
unused: true,
used: false,
})
const typeFilters = ref<Record<number, boolean>>(
componentTypes.value.reduce(
(acc, type) => {
acc[type.id] = true
return acc
},
{} as { [key: number]: boolean }
),
})
{} as Record<number, boolean>
)
)
const filteredComponents = computed(() => {
return components.filter((component) => {
const isUsed = filters.showUsed && component.computerId !== null
const isUnused = filters.showUnused && component.computerId === null
const isTypeSelected = filters.types[component.type.id]
const isUsed = usageFilters.value.used && component.computerId !== null
const isUnused = usageFilters.value.unused && component.computerId === null
const isTypeSelected = typeFilters.value[component.type.id]
return (isUsed || isUnused) && isTypeSelected
})
})
</script>
<template>
<div>
<!-- Dropdown pour les filtres -->
<div class="relative">
<h4 class="text-xl underline">Filters</h4>
<div class="flex gap-2">
<!-- Usage Dropdown -->
<div id="usage-selector" class="relative" style="width: fit-content">
<button
@click="toggleDropdown('usage')"
class="px-4 py-2 bg-indigo-600 text-white rounded hover:bg-blue-700 transition text-center inline-flex items-center"
type="button"
>
Usage
<svg
class="w-4 h-4 ml-2"
aria-hidden="true"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 9l-7 7-7-7"
></path>
</svg>
</button>
<!-- Dropdown menu -->
<div
v-show="usageDropdownOpen"
class="absolute right-0 z-20 py-2 mt-2 bg-white rounded-md shadow-xl"
>
<ul class="space-y-2 text-sm px-4 py-2">
<li class="flex items-center">
<input
id="unused"
type="checkbox"
v-model="filters.showUnused"
class="w-4 h-4 bg-gray-100 border-gray-300 rounded text-primary-600 focus:ring-primary-500 dark:focus:ring-primary-600 dark:ring-offset-gray-700 focus:ring-2 dark:bg-gray-600 dark:border-gray-500"
/>
<label
for="unused"
class="ml-2 text-sm font-medium text-gray-900 dark:text-gray-100"
>
Unused
</label>
</li>
<li class="flex items-center">
<input
id="used"
type="checkbox"
v-model="filters.showUsed"
class="w-4 h-4 bg-gray-100 border-gray-300 rounded text-primary-600 focus:ring-primary-500 dark:focus:ring-primary-600 dark:ring-offset-gray-700 focus:ring-2 dark:bg-gray-600 dark:border-gray-500"
/>
<label for="used" class="ml-2 text-sm font-medium text-gray-900 dark:text-gray-100">
Used
</label>
</li>
</ul>
</div>
</div>
<!-- Type Dropdown -->
<div id="type-selector" class="relative" style="width: fit-content">
<button
@click="toggleDropdown('type')"
class="px-4 py-2 bg-indigo-600 text-white rounded hover:bg-blue-700 transition text-center inline-flex items-center"
type="button"
>
Types
<svg
class="w-4 h-4 ml-2"
aria-hidden="true"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 9l-7 7-7-7"
></path>
</svg>
</button>
<!-- Dropdown menu -->
<div
v-show="typeDropdownOpen"
class="absolute right-0 z-20 py-2 mt-2 bg-white rounded-md shadow-xl"
>
<ul class="space-y-2 text-sm px-4 py-2">
<li class="flex items-center" v-for="type in componentTypes" :key="type.id">
<input
:id="`type-${type.id}`"
type="checkbox"
v-model="filters.types[type.id]"
class="w-4 h-4 bg-gray-100 border-gray-300 rounded text-primary-600 focus:ring-primary-500 dark:focus:ring-primary-600 dark:ring-offset-gray-700 focus:ring-2 dark:bg-gray-600 dark:border-gray-500"
/>
<label
:for="`type-${type.id}`"
class="ml-2 text-sm font-medium text-gray-900 dark:text-gray-100"
>
{{ type.name }}
</label>
</li>
</ul>
</div>
</div>
<FilterDropdown
v-model="usageFilters"
label="Usage"
:options="[
{ id: 'unused', label: 'Unused' },
{ id: 'used', label: 'Used' },
]"
/>
<FilterDropdown v-model="typeFilters" label="Types" :options="typeOptions" />
</div>
</div>
<!-- Tableau des composants filtr├®s -->
<TableComponent :components="filteredComponents" />
</div>
</template>
@@ -46,7 +46,7 @@ const componentTypeOrder = [
'Other',
]
// Trier les composants selon l'ordre sp├®cifi├®
// Trier les composants selon l'ordre spécifié
const sortedComponents = computed(() => {
return components.slice().sort((a, b) => {
return componentTypeOrder.indexOf(a.type.name) - componentTypeOrder.indexOf(b.type.name)
@@ -67,7 +67,7 @@ function getIconPath(typeName: string): string {
'WiFi-Card': wiFiCard,
'Case': pcCase,
}
return typeToIconMap[typeName] || defaultIcon // Utilisez une ic├┤ne par d├®faut si n├®cessaire
return typeToIconMap[typeName] || defaultIcon // Utilisez une icône par défaut si nécessaire
}
</script>
@@ -106,7 +106,7 @@ function getIconPath(typeName: string): string {
</td>
<td v-if="isAdminActive" class="px-4 py-2 border-b border-gray-400 text-right">
Ôé¼{{ component.price }}
€{{ component.price }}
</td>
<td class="px-4 py-2 border-b border-gray-400 flex justify-center items-center">
<Link
@@ -125,7 +125,7 @@ function getIconPath(typeName: string): string {
<tr class="bg-gray-800 text-white">
<td class="px-4 py-2 border-b border-gray-400 whitespace-nowrap" colspan="3">Total</td>
<td class="px-4 py-2 border-b border-gray-400 whitespace-nowrap text-right">
Ôé¼{{ cost.toFixed(2) }}
€{{ cost.toFixed(2) }}
</td>
</tr>
</tfoot>
@@ -0,0 +1,111 @@
<script setup lang="ts">
import { computed, reactive, ref } from 'vue'
import { useForm } from '@inertiajs/vue3'
import type { Computer } from '~/pages/types/UITypes'
const props = defineProps<{
show: boolean
computer: Computer
}>()
const emit = defineEmits(['close'])
const form = reactive({
enabled: props.computer.shareEnabled,
showSellPrice: props.computer.shareShowSellPrice,
showCost: props.computer.shareShowCost,
})
const copied = ref(false)
const shareUrl = computed(() => {
if (!props.computer.shareToken) return ''
return `${window.location.origin}/share/${props.computer.shareToken}`
})
function submit() {
useForm(form).put(`/computers/${props.computer.id}/share`, {
preserveScroll: true,
onSuccess: () => emit('close'),
})
}
function rotateToken() {
useForm({}).post(`/computers/${props.computer.id}/share/rotate`, {
preserveScroll: true,
})
}
async function copyLink() {
await navigator.clipboard.writeText(shareUrl.value)
copied.value = true
setTimeout(() => (copied.value = false), 2000)
}
</script>
<template>
<div v-if="show" class="fixed inset-0 flex items-center justify-center z-50">
<div class="fixed inset-0 bg-black opacity-50" @click="emit('close')"></div>
<div class="bg-white rounded-lg p-6 z-10 w-full max-w-md mx-4">
<h2 class="text-lg font-semibold mb-4 text-gray-800">Share this build</h2>
<label class="flex items-center gap-2 mb-4 cursor-pointer">
<input v-model="form.enabled" type="checkbox" class="w-4 h-4" />
<span class="text-sm font-medium text-gray-700">Enable public link</span>
</label>
<div v-if="form.enabled" class="space-y-3 mb-4 pl-6">
<label class="flex items-center gap-2 cursor-pointer">
<input v-model="form.showSellPrice" type="checkbox" class="w-4 h-4" />
<span class="text-sm text-gray-700">Show sell price</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input v-model="form.showCost" type="checkbox" class="w-4 h-4" />
<span class="text-sm text-gray-700">Show components cost</span>
</label>
</div>
<div v-if="computer.shareEnabled && computer.shareToken" class="mb-4">
<label class="block text-gray-700 text-sm font-bold mb-2">Public link</label>
<div class="flex gap-2">
<input
:value="shareUrl"
readonly
class="flex-1 border-gray-200 px-3 py-2 border rounded text-gray-700 text-sm bg-gray-50"
/>
<button
type="button"
@click="copyLink"
class="px-3 py-2 bg-gray-200 text-gray-700 rounded hover:bg-gray-300 text-sm shrink-0"
>
{{ copied ? 'Copied!' : 'Copy' }}
</button>
</div>
<button
type="button"
@click="rotateToken"
class="mt-2 text-xs text-red-600 hover:text-red-800 underline"
>
Revoke and generate a new link
</button>
</div>
<div class="flex justify-end space-x-2 mt-6">
<button
type="button"
@click="submit"
class="px-4 py-2 bg-indigo-600 text-white rounded hover:bg-indigo-700"
>
Save
</button>
<button
type="button"
@click="emit('close')"
class="px-4 py-2 bg-gray-300 text-gray-700 rounded hover:bg-gray-400"
>
Close
</button>
</div>
</div>
</div>
</template>
+21 -84
View File
@@ -3,106 +3,45 @@ import { noImagePlaceholder } from '~/assets/images'
import { usePage } from '@inertiajs/vue3'
import Computer from '#models/computer'
import ComputerCard from '~/pages/widgets/ComputerCard.vue'
import { computed, reactive, ref } from 'vue'
import FilterDropdown from '~/pages/widgets/FilterDropdown.vue'
import { computed, ref } from 'vue'
const { computers } = usePage<{ computers: Computer[] }>().props
const stateDropdownOpen = ref(false)
function toggleDropdown() {
stateDropdownOpen.value = !stateDropdownOpen.value
}
const filters = reactive({
const filters = ref<Record<string, boolean>>({
onGoing: true,
finished: true,
sold: false,
})
const stateToFilterKey: Record<string, string> = {
ON_GOING: 'onGoing',
FINISHED: 'finished',
SOLD: 'sold',
}
const filteredComputers = computed(() => {
return computers.filter((computer) => {
const isOnGoing = filters.onGoing && computer.state.name == 'ON_GOING'
const isFinished = filters.finished && computer.state.name == 'FINISHED'
const isSold = filters.sold && computer.state.name == 'SOLD'
return isOnGoing || isFinished || isSold
const key = stateToFilterKey[computer.state.name]
return key ? filters.value[key] : false
})
})
</script>
<template>
<div>
<!-- Dropdown pour les filtres -->
<div class="relative">
<h4 class="text-xl underline">Filters</h4>
<div class="flex gap-2">
<!-- Usage Dropdown -->
<div id="usage-selector" class="relative" style="width: fit-content">
<button
@click="toggleDropdown()"
class="px-4 py-2 bg-indigo-600 text-white rounded hover:bg-blue-700 transition text-center inline-flex items-center"
type="button"
>
Usage
<svg
class="w-4 h-4 ml-2"
aria-hidden="true"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 9l-7 7-7-7"
></path>
</svg>
</button>
<!-- Dropdown menu -->
<div
v-show="stateDropdownOpen"
class="absolute right-0 z-20 py-2 mt-2 bg-white rounded-md shadow-xl"
>
<ul class="space-y-2 text-sm px-4 py-2">
<li class="flex items-center">
<input
id="unused"
type="checkbox"
v-model="filters.onGoing"
class="w-4 h-4 bg-gray-100 border-gray-300 rounded text-primary-600 focus:ring-primary-500 dark:focus:ring-primary-600 dark:ring-offset-gray-700 focus:ring-2 dark:bg-gray-600 dark:border-gray-500"
/>
<label
for="unused"
class="ml-2 text-sm font-medium text-gray-900 dark:text-gray-100"
>
On Going
</label>
</li>
<li class="flex items-center">
<input
id="used"
type="checkbox"
v-model="filters.finished"
class="w-4 h-4 bg-gray-100 border-gray-300 rounded text-primary-600 focus:ring-primary-500 dark:focus:ring-primary-600 dark:ring-offset-gray-700 focus:ring-2 dark:bg-gray-600 dark:border-gray-500"
/>
<label for="used" class="ml-2 text-sm font-medium text-gray-900 dark:text-gray-100">
Finished
</label>
</li>
<li class="flex items-center">
<input
id="used"
type="checkbox"
v-model="filters.sold"
class="w-4 h-4 bg-gray-100 border-gray-300 rounded text-primary-600 focus:ring-primary-500 dark:focus:ring-primary-600 dark:ring-offset-gray-700 focus:ring-2 dark:bg-gray-600 dark:border-gray-500"
/>
<label for="used" class="ml-2 text-sm font-medium text-gray-900 dark:text-gray-100">
Sold
</label>
</li>
</ul>
</div>
</div>
<FilterDropdown
v-model="filters"
label="Usage"
:options="[
{ id: 'onGoing', label: 'On Going' },
{ id: 'finished', label: 'Finished' },
{ id: 'sold', label: 'Sold' },
]"
/>
</div>
<div class="mt-4 mb-3 grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4 gap-4">
<ComputerCard
@@ -119,5 +58,3 @@ const filteredComputers = computed(() => {
</div>
</div>
</template>
<style scoped></style>
+72
View File
@@ -0,0 +1,72 @@
<script setup lang="ts">
import { ref } from 'vue'
interface FilterOption {
id: string | number
label: string
}
defineProps<{
label: string
options: FilterOption[]
}>()
const modelValue = defineModel<Record<string | number, boolean>>({ required: true })
const isOpen = ref(false)
function toggle() {
isOpen.value = !isOpen.value
}
</script>
<template>
<div class="relative" style="width: fit-content">
<button
type="button"
class="px-4 py-2 bg-indigo-600 text-white rounded hover:bg-blue-700 transition text-center inline-flex items-center"
:aria-expanded="isOpen"
aria-haspopup="true"
@click="toggle"
>
{{ label }}
<svg
class="w-4 h-4 ml-2"
aria-hidden="true"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 9l-7 7-7-7"
></path>
</svg>
</button>
<div
v-show="isOpen"
class="absolute right-0 z-20 py-2 mt-2 bg-white rounded-md shadow-xl"
>
<ul class="space-y-2 text-sm px-4 py-2">
<li v-for="option in options" :key="option.id" class="flex items-center">
<input
:id="`filter-${label}-${option.id}`"
v-model="modelValue[option.id]"
type="checkbox"
class="w-4 h-4 bg-gray-100 border-gray-300 rounded text-primary-600 focus:ring-primary-500 dark:focus:ring-primary-600 dark:ring-offset-gray-700 focus:ring-2 dark:bg-gray-600 dark:border-gray-500"
/>
<label
:for="`filter-${label}-${option.id}`"
class="ml-2 text-sm font-medium text-gray-900 dark:text-gray-100"
>
{{ option.label }}
</label>
</li>
</ul>
</div>
</div>
</template>
+194
View File
@@ -0,0 +1,194 @@
<script setup lang="ts">
import { reactive, ref, watch } from 'vue'
import type { MerchantOffer, ScrapedPart } from '~/pages/types/UITypes'
const emit = defineEmits<{
import: [part: ScrapedPart, offer: MerchantOffer | null]
}>()
function faviconUrl(source: string): string {
return `https://www.google.com/s2/favicons?domain=${encodeURIComponent(source)}&sz=32`
}
const query = ref('')
const results = ref<ScrapedPart[]>([])
const isLoading = ref(false)
const hasSearched = ref(false)
let debounceTimer: ReturnType<typeof setTimeout> | null = null
// Per-part price comparison state, keyed by scraped part id.
const comparing = reactive<Record<number, boolean>>({})
const offers = reactive<Record<number, MerchantOffer[]>>({})
const compareErrors = reactive<Record<number, boolean>>({})
async function search() {
if (!query.value.trim()) {
results.value = []
hasSearched.value = false
return
}
isLoading.value = true
try {
const response = await fetch(`/scraped-parts?q=${encodeURIComponent(query.value.trim())}`, {
headers: { Accept: 'application/json' },
})
if (response.ok) {
results.value = await response.json()
} else {
results.value = []
}
} finally {
isLoading.value = false
hasSearched.value = true
}
}
watch(query, () => {
if (debounceTimer) clearTimeout(debounceTimer)
debounceTimer = setTimeout(search, 400)
})
async function comparePrices(part: ScrapedPart) {
comparing[part.id] = true
compareErrors[part.id] = false
try {
const response = await fetch(`/scraped-parts/compare?id=${part.id}`, {
headers: { Accept: 'application/json' },
})
if (!response.ok) {
compareErrors[part.id] = true
return
}
const data = await response.json()
offers[part.id] = data.offers ?? []
// The backend already persists this exact value server-side (see
// ScrapedPartsController#compare): the cheapest offer's price when
// one was found (updatePriceIfCheaper), or null when no merchant
// confirmed a price at all (clearPriceIfNoOffers) — an unverified
// pc-builder.io catalog price is not kept around just because the
// comparison came back empty. Mirror that here on the locally held
// result so the display (e.g. "€979.95" or "No price yet") reflects
// it immediately, instead of the initial search-result price sticking
// around until the user re-runs the search from scratch.
part.price = data.price ?? null
if (offers[part.id].length === 0) {
compareErrors[part.id] = true
}
} catch {
compareErrors[part.id] = true
} finally {
comparing[part.id] = false
}
}
function cheapestOffer(part: ScrapedPart): MerchantOffer | null {
return offers[part.id]?.[0] ?? null
}
function merchantDomain(merchant: MerchantOffer['merchant']): string {
const domains: Record<MerchantOffer['merchant'], string> = {
'Amazon': 'amazon.fr',
'LDLC': 'ldlc.com',
'Grosbill': 'grosbill.com',
'TopAchat': 'topachat.com',
'Materiel.net': 'materiel.net',
'Cdiscount': 'cdiscount.com',
'PCComponentes': 'pccomponentes.com',
}
return domains[merchant]
}
function importPart(part: ScrapedPart) {
emit('import', part, cheapestOffer(part))
}
</script>
<template>
<div class="border border-gray-200 rounded-lg p-4 bg-gray-50">
<label class="block text-gray-700 text-sm font-bold mb-2">
Search a new part (pc-builder.io)
</label>
<input
v-model="query"
type="text"
placeholder="e.g. Ryzen 5 7600X, RTX 4070..."
class="w-full border-gray-200 focus:border-indigo-600 focus:ring focus:ring-indigo-500/40 px-3 py-2 border rounded text-gray-700"
/>
<p v-if="isLoading" class="text-sm text-gray-500 mt-2">Searching…</p>
<p v-else-if="hasSearched && results.length === 0" class="text-sm text-gray-500 mt-2">
No matching part found.
</p>
<p v-else-if="results.length > 0" class="text-xs text-gray-500 mt-2">
{{ results.length }} result{{ results.length > 1 ? 's' : '' }} — scroll for more
</p>
<ul v-if="results.length > 0" class="mt-1 space-y-2 max-h-96 overflow-y-auto">
<li
v-for="part in results"
:key="part.id"
class="bg-white border border-gray-200 rounded p-2"
>
<div class="flex items-center gap-3">
<img
v-if="part.imageUrl"
:src="part.imageUrl"
alt=""
class="w-10 h-10 object-contain shrink-0"
/>
<div class="flex-1 min-w-0">
<p class="text-sm text-gray-800 truncate" :title="part.name">{{ part.name }}</p>
<p class="text-xs text-gray-500">
{{ part.type.name }}
<span v-if="part.price !== null"> · €{{ part.price.toFixed(2) }}</span>
<span v-else class="text-gray-400"> · No price yet</span>
</p>
</div>
<button
type="button"
class="px-3 py-1 text-sm bg-indigo-600 text-white rounded hover:bg-indigo-700 transition shrink-0"
@click="importPart(part)"
>
Import
</button>
</div>
<div class="mt-2 pl-13 flex items-center gap-2 flex-wrap">
<button
v-if="!offers[part.id]"
type="button"
:disabled="comparing[part.id]"
class="text-xs px-2 py-1 border border-gray-300 rounded text-gray-600 hover:bg-gray-100 transition disabled:opacity-50"
@click="comparePrices(part)"
>
{{ comparing[part.id] ? 'Checking prices…' : 'Compare prices' }}
</button>
<template v-else-if="cheapestOffer(part)">
<img
:src="faviconUrl(merchantDomain(cheapestOffer(part)!.merchant))"
alt=""
class="w-4 h-4 shrink-0"
/>
<a
:href="cheapestOffer(part)!.url"
target="_blank"
rel="noopener noreferrer"
class="text-xs text-indigo-600 hover:underline"
>
{{ cheapestOffer(part)!.merchant }}: €{{ cheapestOffer(part)!.price.toFixed(2) }}
(cheapest)
</a>
</template>
<span v-if="compareErrors[part.id]" class="text-xs text-gray-400">
No price found on any merchant for this part.
</span>
</div>
</li>
</ul>
</div>
</template>
+1 -1
View File
@@ -165,7 +165,7 @@ function handleDragEnter(index: number) {
const sourceArray =
dragIndex.value < serverImages.value.length ? serverImages.value : newImages.value
const draggedItem = sourceArray.splice(dragIndex.value, 1)[0]
sourceArray.splice(index, 0, draggedItem as any) // Utilisation de 'as any' pour r├®soudre le probl├¿me de typage
sourceArray.splice(index, 0, draggedItem as any) // Utilisation de 'as any' pour résoudre le problème de typage
dragIndex.value = index
}
}
+2 -2
View File
@@ -38,7 +38,7 @@ function getIconPath(typeName: string): string {
'WiFi-Card': wiFiCard,
'Case': pcCase,
}
return typeToIconMap[typeName] || defaultIcon // Utilisez une ic├┤ne par d├®faut si n├®cessaire
return typeToIconMap[typeName] || defaultIcon // Utilisez une icône par défaut si nécessaire
}
function goToDetail(id: number) {
@@ -105,7 +105,7 @@ function deleteComponent(id: number) {
{{ component.name }}
</td>
<td v-if="isAdminActive" class="px-6 py-4 border-b border-gray-200 whitespace-nowrap">
Ôé¼{{ component.price }}
€{{ component.price }}
</td>
<td class="px-6 py-4 border-b border-gray-200 whitespace-nowrap">
{{ component.purchaseDate }}
+655 -161
View File
File diff suppressed because it is too large. Load diff
+3
View File
@@ -42,6 +42,7 @@
"@adonisjs/eslint-config": "^3.1.0",
"@adonisjs/prettier-config": "^1.5.0",
"@adonisjs/tsconfig": "^2.0.0",
"@japa/api-client": "^3.2.1",
"@japa/assert": "^4.2.0",
"@japa/browser-client": "^2.3.0",
"@japa/plugin-adonisjs": "^5.2.0",
@@ -80,9 +81,11 @@
"@vue/server-renderer": "^3.5.35",
"@vuepic/vue-datepicker": "^13.0.0",
"better-sqlite3": "^12.10.0",
"cheerio": "^1.2.0",
"edge.js": "^6.5.1",
"luxon": "^3.7.2",
"mysql2": "^3.22.4",
"playwright-core": "^1.61.1",
"reflect-metadata": "^0.2.2",
"uuid": "^14.0.1",
"vue": "^3.5.35",
+19
View File
@@ -0,0 +1,19 @@
/*
|--------------------------------------------------------------------------
| Headless browser lifecycle
|--------------------------------------------------------------------------
|
| The merchant price comparison feature (app/services/merchant_price_service.ts)
| launches a single shared headless Chromium instance on demand (see
| app/services/headless_browser_service.ts). This makes sure that process
| is closed when the app shuts down, instead of leaking a Chromium child
| process.
|
*/
import app from '@adonisjs/core/services/app'
app.terminating(async () => {
const { closeBrowser } = await import('#services/headless_browser_service')
await closeBrowser()
})
+3
View File
@@ -24,4 +24,7 @@ export default await Env.create(new URL('../', import.meta.url), {
// Session
SESSION_DRIVER: Env.schema.enum(['cookie', 'memory', 'database'] as const),
// Merchant price comparison (headless browser scraping)
CHROMIUM_EXECUTABLE_PATH: Env.schema.string.optional(),
})
+21 -3
View File
@@ -11,9 +11,11 @@ import { middleware } from '#start/kernel'
import { controllers } from '#generated/controllers'
import router from '@adonisjs/core/services/router'
router.get('/', async ({ response }) => {
return response.redirect('/auth/login')
}).as('home')
router
.get('/', async ({ response }) => {
return response.redirect('/auth/login')
})
.as('home')
router
.group(() => {
@@ -42,10 +44,16 @@ router
router.get('/:id/edit', [controllers.Computers, 'edit']).as('computers.edit')
router.put('/:id', [controllers.Computers, 'update']).as('computers.update')
router.delete('/:id', [controllers.Computers, 'destroy']).as('computers.destroy')
router.put('/:id/share', [controllers.ComputerShare, 'update']).as('computers.share.update')
router
.post('/:id/share/rotate', [controllers.ComputerShare, 'rotate'])
.as('computers.share.rotate')
})
.prefix('/computers')
.use(middleware.auth())
router.get('/share/:token', [controllers.PublicComputer, 'show']).as('share.show')
router
.group(() => {
router.get('/', [controllers.Components, 'index']).as('components.index')
@@ -58,3 +66,13 @@ router
})
.prefix('/components')
.use(middleware.auth())
router
.get('/scraped-parts', [controllers.ScrapedParts, 'index'])
.as('scraped_parts.index')
.use(middleware.auth())
router
.get('/scraped-parts/compare', [controllers.ScrapedParts, 'compare'])
.as('scraped_parts.compare')
.use(middleware.auth())
+17 -2
View File
@@ -1,9 +1,13 @@
import { assert } from '@japa/assert'
import { apiClient } from '@japa/api-client'
import app from '@adonisjs/core/services/app'
import type { Config } from '@japa/runner/types'
import { pluginAdonisJS } from '@japa/plugin-adonisjs'
import { dbAssertions } from '@adonisjs/lucid/plugins/db'
import testUtils from '@adonisjs/core/services/test_utils'
import { authApiClient } from '@adonisjs/auth/plugins/api_client'
import { sessionApiClient } from '@adonisjs/session/plugins/api_client'
import { shieldApiClient } from '@adonisjs/shield/plugins/api_client'
/**
* This file is imported by the "bin/test.ts" entrypoint file
@@ -13,7 +17,15 @@ import testUtils from '@adonisjs/core/services/test_utils'
* Configure Japa plugins in the plugins array.
* Learn more - https://japa.dev/docs/runner-config#plugins-optional
*/
export const plugins: Config['plugins'] = [assert(), pluginAdonisJS(app), dbAssertions(app)]
export const plugins: Config['plugins'] = [
assert(),
apiClient(),
sessionApiClient(app),
authApiClient(app),
shieldApiClient(),
pluginAdonisJS(app),
dbAssertions(app),
]
/**
* Configure lifecycle function to run before and after all the
@@ -33,6 +45,9 @@ export const runnerHooks: Required<Pick<Config, 'setup' | 'teardown'>> = {
*/
export const configureSuite: Config['configureSuite'] = (suite) => {
if (['browser', 'functional', 'e2e'].includes(suite.name)) {
return suite.setup(() => testUtils.httpServer().start())
suite.setup(() => testUtils.httpServer().start())
suite.onGroup((group) => {
group.each.setup(() => testUtils.db().withGlobalTransaction())
})
}
}
+198
View File
@@ -0,0 +1,198 @@
import { test } from '@japa/runner'
import { DateTime } from 'luxon'
import User from '#models/user'
import Component from '#models/component'
import ComponentType from '#models/component_type'
async function createUser(email: string) {
return User.create({
name: 'Test User',
email,
password: 'secret@123A',
})
}
async function createType(name: string, unique = true) {
return ComponentType.firstOrCreate({ name }, { name, unique })
}
async function createComponent(userId: number, typeId: number) {
return Component.create({
name: 'Test Component',
price: 20,
typeId,
userId,
computerId: null,
purchaseDate: DateTime.now(),
})
}
test.group('Components authorization (IDOR)', () => {
test('owner can update their own component', async ({ client, assert }) => {
const owner = await createUser('owner-comp-update@example.com')
const type = await createType('RAM-comp-1')
const component = await createComponent(owner.id, type.id)
const response = await client
.put(`/components/${component.id}`)
.loginAs(owner)
.withCsrfToken()
.redirects(0)
.json({
name: 'Updated name',
price: 30,
typeId: type.id,
purchaseDate: DateTime.now().toFormat('yyyy-MM-dd'),
})
response.assertStatus(302)
await component.refresh()
assert.equal(component.name, 'Updated name')
})
test('a user cannot update another user component', async ({ client, assert }) => {
const owner = await createUser('owner-comp-a@example.com')
const attacker = await createUser('attacker-comp-a@example.com')
const type = await createType('RAM-comp-2')
const component = await createComponent(owner.id, type.id)
const response = await client
.put(`/components/${component.id}`)
.loginAs(attacker)
.withCsrfToken()
.redirects(0)
.json({
name: 'Hacked name',
price: 1,
typeId: type.id,
purchaseDate: DateTime.now().toFormat('yyyy-MM-dd'),
})
response.assertStatus(404)
await component.refresh()
assert.notEqual(component.name, 'Hacked name')
})
test('a user cannot delete another user component', async ({ client, assert }) => {
const owner = await createUser('owner-comp-b@example.com')
const attacker = await createUser('attacker-comp-b@example.com')
const type = await createType('RAM-comp-3')
const component = await createComponent(owner.id, type.id)
const response = await client
.delete(`/components/${component.id}`)
.loginAs(attacker)
.withCsrfToken()
.redirects(0)
response.assertStatus(404)
const stillExists = await Component.find(component.id)
assert.isNotNull(stillExists)
})
test('an unauthenticated request cannot delete a component', async ({ client, assert }) => {
const owner = await createUser('owner-comp-c@example.com')
const type = await createType('RAM-comp-4')
const component = await createComponent(owner.id, type.id)
const response = await client.delete(`/components/${component.id}`)
response.assertRedirectsTo('/auth/login')
const stillExists = await Component.find(component.id)
assert.isNotNull(stillExists)
})
})
test.group('Components purchase URL', () => {
test('a component can be created with a purchase site URL', async ({ client, assert }) => {
const user = await createUser('purchase-url-a@example.com')
const type = await createType('RAM-purchase-1')
const response = await client
.post('/components')
.loginAs(user)
.withCsrfToken()
.redirects(0)
.json({
name: 'New GPU',
price: 599.99,
typeId: type.id,
purchaseDate: DateTime.now().toFormat('yyyy-MM-dd'),
purchaseUrl: 'https://fr.pc-builder.io/gpu/example-p1',
quantity: 1,
})
response.assertStatus(302)
const component = await Component.query().where('userId', user.id).firstOrFail()
assert.equal(component.purchaseUrl, 'https://fr.pc-builder.io/gpu/example-p1')
})
test('a component can be created without a purchase site URL', async ({ client, assert }) => {
const user = await createUser('purchase-url-b@example.com')
const type = await createType('RAM-purchase-2')
const response = await client
.post('/components')
.loginAs(user)
.withCsrfToken()
.redirects(0)
.json({
name: 'Used RAM stick',
price: 20,
typeId: type.id,
purchaseDate: DateTime.now().toFormat('yyyy-MM-dd'),
quantity: 1,
})
response.assertStatus(302)
const component = await Component.query().where('userId', user.id).firstOrFail()
assert.isNull(component.purchaseUrl)
})
test('an invalid purchase URL is rejected', async ({ client, assert }) => {
const user = await createUser('purchase-url-c@example.com')
const type = await createType('RAM-purchase-3')
const response = await client
.post('/components')
.loginAs(user)
.withCsrfToken()
.redirects(0)
.json({
name: 'Suspicious component',
price: 20,
typeId: type.id,
purchaseDate: DateTime.now().toFormat('yyyy-MM-dd'),
purchaseUrl: 'not-a-url',
quantity: 1,
})
response.assertStatus(302)
assert.property(response.flashMessage('inputErrorsBag'), 'purchaseUrl')
const created = await Component.query().where('userId', user.id).first()
assert.isNull(created)
})
test('the purchase URL can be updated on an existing component', async ({ client, assert }) => {
const owner = await createUser('purchase-url-d@example.com')
const type = await createType('RAM-purchase-4')
const component = await createComponent(owner.id, type.id)
const response = await client
.put(`/components/${component.id}`)
.loginAs(owner)
.withCsrfToken()
.redirects(0)
.json({
name: component.name,
price: component.price,
typeId: type.id,
purchaseDate: DateTime.now().toFormat('yyyy-MM-dd'),
purchaseUrl: 'https://fr.pc-builder.io/cpu/example-p2',
})
response.assertStatus(302)
await component.refresh()
assert.equal(component.purchaseUrl, 'https://fr.pc-builder.io/cpu/example-p2')
})
})
+170
View File
@@ -0,0 +1,170 @@
import { test } from '@japa/runner'
import User from '#models/user'
import Computer from '#models/computer'
import ComputerState from '#models/computer_state'
async function createUser(email: string) {
return User.create({
name: 'Test User',
email,
password: 'secret@123A',
})
}
async function createState(name = 'ON_GOING') {
return ComputerState.firstOrCreate({ name }, { name })
}
async function createComputer(userId: number, stateId: number) {
return Computer.create({
title: 'Shared PC',
sellPrice: 999,
gpuScore: 5,
cpuScore: 5,
globalScore: 5,
stateId,
userId,
})
}
test.group('Computer sharing', () => {
test('owner can enable sharing and the public page becomes reachable', async ({
client,
assert,
}) => {
const owner = await createUser('share-owner-a@example.com')
const state = await createState()
const computer = await createComputer(owner.id, state.id)
const enableResponse = await client
.put(`/computers/${computer.id}/share`)
.loginAs(owner)
.withCsrfToken()
.redirects(0)
.json({ enabled: true, showSellPrice: true, showCost: false })
enableResponse.assertStatus(302)
await computer.refresh()
assert.isTrue(computer.shareEnabled)
assert.isString(computer.shareToken)
const publicResponse = await client
.get(`/share/${computer.shareToken}`)
.header('X-Inertia', 'true')
.header('X-Inertia-Version', '1')
.redirects(0)
publicResponse.assertStatus(200)
publicResponse.assertBodyContains({ props: { computer: { title: 'Shared PC' } } })
})
test('sell price and cost are hidden unless explicitly enabled', async ({ client }) => {
const owner = await createUser('share-owner-b@example.com')
const state = await createState()
const computer = await createComputer(owner.id, state.id)
await client
.put(`/computers/${computer.id}/share`)
.loginAs(owner)
.withCsrfToken()
.redirects(0)
.json({ enabled: true, showSellPrice: false, showCost: false })
await computer.refresh()
const publicResponse = await client
.get(`/share/${computer.shareToken}`)
.header('X-Inertia', 'true')
.header('X-Inertia-Version', '1')
.redirects(0)
publicResponse.assertStatus(200)
publicResponse.assertBodyContains({ props: { computer: { sellPrice: null, cost: null } } })
})
test('disabling sharing makes the public link unreachable', async ({ client }) => {
const owner = await createUser('share-owner-c@example.com')
const state = await createState()
const computer = await createComputer(owner.id, state.id)
await client
.put(`/computers/${computer.id}/share`)
.loginAs(owner)
.withCsrfToken()
.redirects(0)
.json({ enabled: true, showSellPrice: false, showCost: false })
await computer.refresh()
const token = computer.shareToken
await client
.put(`/computers/${computer.id}/share`)
.loginAs(owner)
.withCsrfToken()
.redirects(0)
.json({ enabled: false, showSellPrice: false, showCost: false })
const publicResponse = await client.get(`/share/${token}`).redirects(0)
publicResponse.assertStatus(404)
})
test('a user cannot enable sharing on another user computer', async ({ client }) => {
const owner = await createUser('share-owner-d@example.com')
const attacker = await createUser('share-attacker-d@example.com')
const state = await createState()
const computer = await createComputer(owner.id, state.id)
const response = await client
.put(`/computers/${computer.id}/share`)
.loginAs(attacker)
.withCsrfToken()
.redirects(0)
.json({ enabled: true, showSellPrice: true, showCost: true })
response.assertStatus(404)
})
test('rotating the share token invalidates the previous link', async ({ client, assert }) => {
const owner = await createUser('share-owner-e@example.com')
const state = await createState()
const computer = await createComputer(owner.id, state.id)
await client
.put(`/computers/${computer.id}/share`)
.loginAs(owner)
.withCsrfToken()
.redirects(0)
.json({ enabled: true, showSellPrice: false, showCost: false })
await computer.refresh()
const oldToken = computer.shareToken
await client
.post(`/computers/${computer.id}/share/rotate`)
.loginAs(owner)
.withCsrfToken()
.redirects(0)
await computer.refresh()
assert.notEqual(computer.shareToken, oldToken)
const oldLinkResponse = await client.get(`/share/${oldToken}`).redirects(0)
oldLinkResponse.assertStatus(404)
const newLinkResponse = await client.get(`/share/${computer.shareToken}`).redirects(0)
newLinkResponse.assertStatus(200)
})
test('an unauthenticated visitor can view a shared computer', async ({ client }) => {
const owner = await createUser('share-owner-f@example.com')
const state = await createState()
const computer = await createComputer(owner.id, state.id)
await client
.put(`/computers/${computer.id}/share`)
.loginAs(owner)
.withCsrfToken()
.redirects(0)
.json({ enabled: true, showSellPrice: true, showCost: true })
await computer.refresh()
// No .loginAs() here: simulates a fully anonymous visitor.
const response = await client.get(`/share/${computer.shareToken}`).redirects(0)
response.assertStatus(200)
})
})
+198
View File
@@ -0,0 +1,198 @@
import { test } from '@japa/runner'
import { DateTime } from 'luxon'
import User from '#models/user'
import Computer from '#models/computer'
import ComputerState from '#models/computer_state'
import Component from '#models/component'
import ComponentType from '#models/component_type'
async function createUser(email: string) {
return User.create({
name: 'Test User',
email,
password: 'secret@123A',
})
}
async function createState(name = 'ON_GOING') {
return ComputerState.firstOrCreate({ name }, { name })
}
async function createType(name: string, unique = true) {
return ComponentType.firstOrCreate({ name }, { name, unique })
}
async function createComponent(userId: number, typeId: number, computerId: number | null = null) {
return Component.create({
name: 'Test Component',
price: 42.5,
typeId,
userId,
computerId,
purchaseDate: DateTime.now(),
})
}
async function createComputer(userId: number, stateId: number) {
return Computer.create({
title: 'My PC',
sellPrice: 100,
gpuScore: 1,
cpuScore: 1,
globalScore: 1,
stateId,
userId,
})
}
test.group('Computers authorization (IDOR)', () => {
test('owner can update their own computer', async ({ client, assert }) => {
const owner = await createUser('owner-update@example.com')
const state = await createState()
const computer = await createComputer(owner.id, state.id)
const response = await client
.put(`/computers/${computer.id}`)
.loginAs(owner)
.withCsrfToken()
.redirects(0)
.json({
title: 'Updated title',
sellPrice: 150,
gpuScore: 2,
cpuScore: 2,
globalScore: 2,
stateId: state.id,
componentsId: [],
pictures: [],
})
response.assertStatus(302)
await computer.refresh()
assert.equal(computer.title, 'Updated title')
})
test('a user cannot update another user computer', async ({ client }) => {
const owner = await createUser('owner-a@example.com')
const attacker = await createUser('attacker-a@example.com')
const state = await createState()
const computer = await createComputer(owner.id, state.id)
const response = await client
.put(`/computers/${computer.id}`)
.loginAs(attacker)
.withCsrfToken()
.redirects(0)
.json({
title: 'Hacked title',
sellPrice: 1,
gpuScore: 1,
cpuScore: 1,
globalScore: 1,
stateId: state.id,
componentsId: [],
pictures: [],
})
response.assertStatus(404)
})
test('a user cannot delete another user computer', async ({ client, assert }) => {
const owner = await createUser('owner-b@example.com')
const attacker = await createUser('attacker-b@example.com')
const state = await createState()
const computer = await createComputer(owner.id, state.id)
const response = await client
.delete(`/computers/${computer.id}`)
.loginAs(attacker)
.withCsrfToken()
.redirects(0)
response.assertStatus(404)
const stillExists = await Computer.find(computer.id)
assert.isNotNull(stillExists)
})
test('a user cannot attach another user component via componentsId', async ({
client,
assert,
}) => {
const owner = await createUser('owner-c@example.com')
const attacker = await createUser('attacker-c@example.com')
const state = await createState()
const type = await createType('Processor-IDOR', true)
const victimComponent = await createComponent(owner.id, type.id)
const attackerComputer = await createComputer(attacker.id, state.id)
const response = await client
.put(`/computers/${attackerComputer.id}`)
.loginAs(attacker)
.withCsrfToken()
.redirects(0)
.json({
title: attackerComputer.title,
sellPrice: attackerComputer.sellPrice,
gpuScore: 0,
cpuScore: 0,
globalScore: 0,
stateId: state.id,
componentsId: [victimComponent.id],
pictures: [],
})
// handleComponents scopes the lookup to the current user, so the
// victim's component cannot be found/attached -> 404.
response.assertStatus(404)
await victimComponent.refresh()
assert.isNull(victimComponent.computerId)
})
test('cannot attach two components of the same unique type to a computer', async ({ client }) => {
const owner = await createUser('owner-d@example.com')
const state = await createState()
const type = await createType('Motherboard-IDOR', true)
const componentA = await createComponent(owner.id, type.id)
const componentB = await createComponent(owner.id, type.id)
const computer = await createComputer(owner.id, state.id)
const response = await client
.put(`/computers/${computer.id}`)
.loginAs(owner)
.withCsrfToken()
.redirects(0)
.json({
title: computer.title,
sellPrice: computer.sellPrice,
gpuScore: 0,
cpuScore: 0,
globalScore: 0,
stateId: state.id,
componentsId: [componentA.id, componentB.id],
pictures: [],
})
response.assertStatus(422)
})
test('deleting a computer detaches its components instead of destroying them', async ({
client,
assert,
}) => {
const owner = await createUser('owner-e@example.com')
const state = await createState()
const type = await createType('Fan-IDOR', false)
const computer = await createComputer(owner.id, state.id)
const component = await createComponent(owner.id, type.id, computer.id)
const response = await client
.delete(`/computers/${computer.id}`)
.loginAs(owner)
.withCsrfToken()
.redirects(0)
response.assertStatus(302)
await component.refresh()
assert.isNull(component.computerId)
})
})
@@ -0,0 +1,46 @@
import { test } from '@japa/runner'
import { extractModelToken, labelMatchesModelToken } from '#services/merchant_price_service'
test.group('extractModelToken', () => {
test('extracts a bare model number', ({ assert }) => {
assert.equal(extractModelToken('Samsung 970 Evo Plus 1 TB Solid State Drive'), '970')
})
test('extracts an alphanumeric model number', ({ assert }) => {
assert.equal(extractModelToken('AMD Ryzen 3 3200G 3.6 GHz Quad-Core Processor'), '3200G')
})
test('appends a known suffix immediately following the model number', ({ assert }) => {
assert.equal(extractModelToken('NVIDIA GeForce RTX 5060 Ti 16 GB Video Card'), '5060 Ti')
assert.equal(extractModelToken('NVIDIA GeForce RTX 4070 Super 12 GB Video Card'), '4070 Super')
})
test('does not append a suffix when the model number has none', ({ assert }) => {
assert.equal(extractModelToken('NVIDIA GeForce RTX 5060 8 GB Video Card'), '5060')
})
test('returns null when no model-like token is found', ({ assert }) => {
assert.isNull(extractModelToken('Some Generic Product Without Any Model Number'))
})
})
test.group('labelMatchesModelToken', () => {
test('a bare model token matches a label with no suffix', ({ assert }) => {
assert.isTrue(labelMatchesModelToken('MSI VENTUS 2X OC NVIDIA GeForce RTX 5060 8G', '5060'))
})
test('a bare model token does NOT match a label carrying a known suffix', ({ assert }) => {
// Regression: RTX 5060 vs RTX 5060 Ti must never be conflated - they
// are different, differently-priced cards.
assert.isFalse(labelMatchesModelToken('PNY NVIDIA GeForce RTX 5060 Ti 8G OC', '5060'))
})
test('a suffixed model token only matches labels with that exact suffix', ({ assert }) => {
assert.isTrue(labelMatchesModelToken('PNY NVIDIA GeForce RTX 5060 Ti 8G OC', '5060 Ti'))
assert.isFalse(labelMatchesModelToken('MSI VENTUS 2X OC NVIDIA GeForce RTX 5060 8G', '5060 Ti'))
})
test('returns false when the token is not present at all', ({ assert }) => {
assert.isFalse(labelMatchesModelToken('AMD Radeon RX 7800 XT', '5060'))
})
})
+301
View File
@@ -0,0 +1,301 @@
import { test } from '@japa/runner'
import { DateTime } from 'luxon'
import User from '#models/user'
import ComponentType from '#models/component_type'
import ScrapedPart from '#models/scraped_part'
import {
isCategoryStale,
ensureFreshCategory,
updatePriceIfCheaper,
clearPriceIfNoOffers,
shouldKeepExistingPrice,
CACHE_TTL_HOURS,
} from '#services/part_scraper_service'
async function createUser(email: string) {
return User.create({
name: 'Test User',
email,
password: 'secret@123A',
})
}
async function createType(name: string, unique = true) {
return ComponentType.firstOrCreate({ name }, { name, unique })
}
async function createScrapedPart(
typeId: number,
name: string,
sourceRef: string,
price: number | null = 199.99
) {
return ScrapedPart.create({
typeId,
name,
price,
imageUrl: 'https://fr.pc-builder.io/images/cpu/example.jpg',
source: 'pc-builder.io',
sourceUrl: `https://fr.pc-builder.io/cpu/${sourceRef}`,
sourceRef,
scrapedAt: DateTime.now(),
})
}
test.group('Scraped parts search', () => {
test('an authenticated user can search scraped parts by name', async ({ client, assert }) => {
const user = await createUser('scraped-search-a@example.com')
const type = await createType('Processor-scraped-1')
await createScrapedPart(type.id, 'ZZZ-Test-Ryzen-Unique 5 7600X', 'amd-ryzen-5-7600x-p1311')
await createScrapedPart(type.id, 'ZZZ-Test-Intel-Unique i5-13600K', 'intel-i5-13600k-p9999')
const response = await client.get('/scraped-parts?q=ZZZ-Test-Ryzen-Unique').loginAs(user)
response.assertStatus(200)
const body = response.body()
assert.lengthOf(body, 1)
assert.equal(body[0].name, 'ZZZ-Test-Ryzen-Unique 5 7600X')
assert.equal(body[0].type.id, type.id)
})
test('search can be filtered by typeId', async ({ client, assert }) => {
const user = await createUser('scraped-search-b@example.com')
const cpuType = await createType('Processor-scraped-2')
const gpuType = await createType('Graphic Card-scraped-2')
await createScrapedPart(cpuType.id, 'ZZZ-Test-AMD Ryzen 7 7800X3D', 'amd-ryzen-7-7800x3d-p1')
await createScrapedPart(gpuType.id, 'ZZZ-Test-AMD Radeon RX 7800', 'amd-radeon-rx-7800-p2')
const response = await client
.get(`/scraped-parts?q=ZZZ-Test-AMD&typeId=${gpuType.id}`)
.loginAs(user)
response.assertStatus(200)
const body = response.body()
assert.lengthOf(body, 1)
assert.equal(body[0].name, 'ZZZ-Test-AMD Radeon RX 7800')
})
test('an unauthenticated request is redirected to login', async ({ client }) => {
const response = await client.get('/scraped-parts?q=ryzen')
response.assertRedirectsTo('/auth/login')
})
test('parts without a known price are kept and sorted last', async ({ client, assert }) => {
const user = await createUser('scraped-search-c@example.com')
const type = await createType('Graphic Card-scraped-3')
await createScrapedPart(type.id, 'ZZZ-Test-RTX Cheap', 'rtx-cheap-p1', 300)
await createScrapedPart(type.id, 'ZZZ-Test-RTX Expensive', 'rtx-expensive-p2', 900)
await createScrapedPart(type.id, 'ZZZ-Test-RTX NoPrice', 'rtx-noprice-p3', null)
const response = await client.get('/scraped-parts?q=ZZZ-Test-RTX').loginAs(user)
response.assertStatus(200)
const body = response.body()
assert.lengthOf(body, 3)
assert.equal(body[0].name, 'ZZZ-Test-RTX Cheap')
assert.equal(body[1].name, 'ZZZ-Test-RTX Expensive')
assert.equal(body[2].name, 'ZZZ-Test-RTX NoPrice')
assert.isNull(body[2].price)
})
})
test.group('Scraped parts 24h cache freshness', () => {
test('a category with no cached parts is considered stale', async ({ assert }) => {
const type = await createType('Processor-freshness-1')
assert.isTrue(await isCategoryStale(type))
})
test('a category scraped within the last 24h is not stale', async ({ assert }) => {
const type = await createType('Processor-freshness-2')
await ScrapedPart.create({
typeId: type.id,
name: 'ZZZ-Test-Fresh Part',
price: 100,
imageUrl: null,
source: 'pc-builder.io',
sourceUrl: 'https://fr.pc-builder.io/cpu/fresh-part',
sourceRef: 'fresh-part-ref',
scrapedAt: DateTime.now().minus({ hours: 1 }),
})
assert.isFalse(await isCategoryStale(type))
})
test('a category scraped more than 24h ago is stale', async ({ assert }) => {
const type = await createType('Processor-freshness-3')
await ScrapedPart.create({
typeId: type.id,
name: 'ZZZ-Test-Stale Part',
price: 100,
imageUrl: null,
source: 'pc-builder.io',
sourceUrl: 'https://fr.pc-builder.io/cpu/stale-part',
sourceRef: 'stale-part-ref',
scrapedAt: DateTime.now().minus({ hours: CACHE_TTL_HOURS + 1 }),
})
assert.isTrue(await isCategoryStale(type))
})
test('ensureFreshCategory does not scrape unmapped types (no network call)', async ({
assert,
}) => {
// "Other" is a real, unmapped ComponentType (see CATEGORY_SLUGS): this
// must resolve without making any outbound HTTP request.
const type = await createType('Other-freshness-unmapped', false)
await assert.doesNotReject(() => ensureFreshCategory(type))
})
})
test.group('Scraped parts merchant price comparison', () => {
test('an unauthenticated request is redirected to login', async ({ client }) => {
const response = await client.get('/scraped-parts/compare?id=1')
response.assertRedirectsTo('/auth/login')
})
test('comparing an unknown scraped part returns 404', async ({ client }) => {
const user = await createUser('compare-unknown@example.com')
const response = await client.get('/scraped-parts/compare?id=999999999').loginAs(user)
response.assertStatus(404)
})
test('an invalid id is rejected by validation', async ({ client }) => {
const user = await createUser('compare-invalid@example.com')
const response = await client
.get('/scraped-parts/compare?id=not-a-number')
.header('Accept', 'application/json')
.loginAs(user)
response.assertStatus(422)
})
})
test.group('Best-known-price persistence', () => {
test('a cheaper offer replaces the current price', async ({ assert }) => {
const type = await createType('Graphic Card-price-1')
const part = await createScrapedPart(type.id, 'ZZZ-Test-Price-A', 'price-a-p1', 500)
await updatePriceIfCheaper(part, { merchant: 'LDLC', price: 350, url: 'https://ldlc.com/x' })
await part.refresh()
assert.equal(part.price, 350)
assert.equal(part.priceSource, 'LDLC')
assert.equal(part.sourceUrl, 'https://ldlc.com/x')
assert.isNotNull(part.priceCheckedAt)
})
test('a more expensive offer does not replace the current price', async ({ assert }) => {
const type = await createType('Graphic Card-price-2')
const part = await createScrapedPart(type.id, 'ZZZ-Test-Price-B', 'price-b-p1', 300)
await updatePriceIfCheaper(part, { merchant: 'LDLC', price: 450, url: 'https://ldlc.com/y' })
await part.refresh()
assert.equal(part.price, 300)
assert.isNull(part.priceSource)
})
test('an offer is accepted when no price was known yet', async ({ assert }) => {
const type = await createType('Graphic Card-price-3')
const part = await createScrapedPart(type.id, 'ZZZ-Test-Price-C', 'price-c-p1', null)
await updatePriceIfCheaper(part, {
merchant: 'Grosbill',
price: 599.99,
url: 'https://grosbill.com/z',
})
await part.refresh()
assert.equal(part.price, 599.99)
assert.equal(part.priceSource, 'Grosbill')
})
})
test.group('clearPriceIfNoOffers', () => {
test('clears an unverified pc-builder.io catalog price when no merchant confirms it', async ({
assert,
}) => {
// Regression: memory sticks kept showing pc-builder.io catalog prices
// (e.g. €35.99) even though comparing prices found no matching offer
// on any merchant ("No live price found, using catalog price €35.99")
// — an unconfirmed number should not be displayed as if it were still
// valid.
const type = await createType('Memory-clear-1')
const part = await createScrapedPart(type.id, 'ZZZ-Test-Clear-A', 'clear-a-p1', 35.99)
await clearPriceIfNoOffers(part)
await part.refresh()
assert.isNull(part.price)
assert.isNull(part.priceSource)
})
test('also clears a price previously verified by a merchant if no longer confirmed', async ({
assert,
}) => {
const type = await createType('Memory-clear-2')
const part = await createScrapedPart(type.id, 'ZZZ-Test-Clear-B', 'clear-b-p1', 50)
part.priceSource = 'LDLC'
await part.save()
await clearPriceIfNoOffers(part)
await part.refresh()
assert.isNull(part.price)
assert.isNull(part.priceSource)
})
test('is a no-op when the part already has no price', async ({ assert }) => {
const type = await createType('Memory-clear-3')
const part = await createScrapedPart(type.id, 'ZZZ-Test-Clear-C', 'clear-c-p1', null)
const originalCheckedAt = part.priceCheckedAt
await clearPriceIfNoOffers(part)
await part.refresh()
assert.isNull(part.price)
assert.equal(part.priceCheckedAt, originalCheckedAt)
})
})
test.group('Catalog refresh price protection (shouldKeepExistingPrice)', () => {
test('an unverified pc-builder.io price is NOT protected and gets overwritten', ({ assert }) => {
// Regression test: a GPU had price=431.17 from a previous pc-builder.io
// catalog scrape (priceSource: null, never independently verified).
// pc-builder.io later stopped showing a fixed price for it (only a
// generic Amazon search link). The stale 431.17 must NOT be kept.
const existing = { price: 431.17, priceSource: null }
assert.isFalse(shouldKeepExistingPrice(existing, null))
assert.isFalse(shouldKeepExistingPrice(existing, 999))
})
test('a verified merchant price IS protected from a worse/missing catalog price', ({
assert,
}) => {
const existing = { price: 350, priceSource: 'LDLC' }
assert.isTrue(shouldKeepExistingPrice(existing, null))
assert.isTrue(shouldKeepExistingPrice(existing, 500))
})
test('a verified merchant price is replaced if the catalog price is actually cheaper', ({
assert,
}) => {
const existing = { price: 350, priceSource: 'LDLC' }
assert.isFalse(shouldKeepExistingPrice(existing, 200))
})
test('no existing part means nothing to keep', ({ assert }) => {
assert.isFalse(shouldKeepExistingPrice(null, 100))
assert.isFalse(shouldKeepExistingPrice(null, null))
})
})