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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
**/node_modules/
**/dist/
**/dist-ssr/
**/coverage/
**/*.tsbuildinfo
**/package-lock.json
backend/data/
7 changes: 7 additions & 0 deletions .prettierrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"singleQuote": false,
"semi": true,
"tabWidth": 2,
"useTabs": false,
"trailingComma": "all"
}
83 changes: 55 additions & 28 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,25 +8,29 @@ Full Circuit is a web application for exploring PC components and planning a cus

## Table of contents

- [Project status](#project-status)
- [Accessibility](#accessibility)
- [Sustainability](#sustainability)
- [Dataset](#dataset)
- [Database](#database)
- [Seed the database](#seed-the-database)
- [Backend](#backend)
- [What is set up on the VM](#what-is-set-up-on-the-vm)
- [Working with it](#working-with-it)
- [Run the backend locally](#run-the-backend-locally)
- [Current prototype](#current-prototype)
- [PC builder](#pc-builder)
- [Navigation and shared build state](#navigation-and-shared-build-state)
- [Published builds](#published-builds)
- [Planned functionality](#planned-functionality)
- [Use of AI](#use-of-ai)
- [Testing](#testing)
- [Run the prototype locally](#run-the-prototype-locally)
- [Working in this repository](#working-in-this-repository)
- [Full Circuit](#full-circuit)
- [Table of contents](#table-of-contents)
- [Project status](#project-status)
- [Accessibility](#accessibility)
- [Sustainability](#sustainability)
- [Dataset](#dataset)
- [Database](#database)
- [Seed the database](#seed-the-database)
- [Backend](#backend)
- [What is set up on the VM](#what-is-set-up-on-the-vm)
- [Working with it](#working-with-it)
- [Run the backend locally](#run-the-backend-locally)
- [Current prototype](#current-prototype)
- [PC builder](#pc-builder)
- [Navigation and shared build state](#navigation-and-shared-build-state)
- [Published builds](#published-builds)
- [Planned functionality](#planned-functionality)
- [Use of AI](#use-of-ai)
- [Testing](#testing)
- [Run the prototype locally](#run-the-prototype-locally)
- [Working in this repository](#working-in-this-repository)
- [Formatting](#formatting)
- [Pull requests](#pull-requests)

## Project status

Expand Down Expand Up @@ -68,7 +72,7 @@ Files in `backend/`:
- `data/parts.csv` – the dataset that is loaded into the database.
- `data/part_images/` – one image per part, named `<part id>.webp`.
- `db/schema.sql` – creates the `parts` and `builds` tables and the parts indexes.
- `db/seed.sql` – empties `parts` and loads `parts.csv`.
- `db/seed.sql` – inserts or updates components from `parts.csv`, preserving existing builds and components missing from the CSV.
- `db/seed.sh` – runs `schema.sql` and `seed.sql`.

Each part is one row with the columns `id`, `category`, `name`, `manufacturer`, `price_eur`, `image_path`, and `specs` (details as `jsonb`). Published builds are saved in the `builds` table with a name, optional comment, date, and references to their selected parts.
Expand All @@ -77,14 +81,22 @@ Each part is one row with the columns `id`, `category`, `name`, `manufacturer`,

The PostgreSQL server and the `buildapc` role are set up on the VM.

To load the data on a new database, run from the repository root:
To initialize or reseed the database, run from the repository root:

```bash
export DATABASE_URL='postgresql://buildapc:change-me@localhost:5432/buildapc'
./backend/db/seed.sh
```

Do not run this script on an existing database without checking the schema first. The `builds` table is not created with `IF NOT EXISTS`, and the seed script is currently meant for initial setup.
Initialization and seeding can be rerun with `./backend/db/seed.sh`.
Existing components are updated by ID, new components are inserted, and
published builds are preserved. Components missing from the CSV are retained.
The scripts run in one transaction, so failures roll back the entire operation.

`IF NOT EXISTS` skips existing tables; it does not migrate their structure.
Future schema changes require separate migrations.

Manually verified on local PostgreSQL 16.15: initial setup and repeated initialization succeeded with 800 components. Reseeding restored a modified component price while preserving an existing build and its CPU reference, with the component count remaining at 800. A deliberate SQL error after seeding rolled back the transaction and preserved the previous price.

## Backend

Expand Down Expand Up @@ -152,13 +164,13 @@ Selections are stored as JSON under the `pc-builder` key in `localStorage` and r

`frontend/src/App.tsx` renders the page based on URL query parameters:

| Page | URL relative to the app base |
| --- | --- |
| Home | No query parameters |
| Explore components | `?page=explore` |
| Page | URL relative to the app base |
| ------------------ | ------------------------------------------- |
| Home | No query parameters |
| Explore components | `?page=explore` |
| Component category | `?category=CPU` (or another category value) |
| PC builder | `?page=builder` |
| Community builds | `?page=community` |
| PC builder | `?page=builder` |
| Community builds | `?page=community` |

The builder takes precedence if both `page=builder` and a category are supplied. Category URLs use the values defined in `frontend/src/types/categories.ts`, such as `CPU`, rather than display labels such as `Processors`. Navigation uses regular links or `window.location.href`, which reload the page; the build provider restores saved selections afterward. Home links use `import.meta.env.BASE_URL` to support deployment under `/project2/`.

Expand Down Expand Up @@ -240,6 +252,21 @@ npm run test # Run frontend tests

## Working in this repository

### Formatting

Frontend and backend share the Prettier rules in the root `.prettierrc`: double quotes, semicolons, two-space indentation, and trailing commas. Generated files and datasets are excluded through `.prettierignore`.

After installing dependencies with `npm ci` in each project, run these commands from the repository root before opening a pull request:

```bash
npm --prefix frontend run format
npm --prefix backend/server run format
```

Use `format:check` instead of `format` to check formatting without changing files. To format supported files across the entire repository, including this README, run `npm exec --prefix frontend -- prettier --write .` from the root. Prettier handles formatting; run `lint`, `build`, and the frontend tests separately to check the code. Prettier does not rename files or fix spelling errors.

### Pull requests

`main` is protected — you cannot push to it directly. Work on a branch and open a pull request:

```bash
Expand Down
2 changes: 1 addition & 1 deletion backend/db/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ CREATE INDEX IF NOT EXISTS parts_cooler_height_idx


/* Saved builds */
CREATE TABLE builds (
CREATE TABLE IF NOT EXISTS builds (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
tittel varchar(50) NOT NULL CHECK (length(trim(tittel)) > 0),
created_at timestamptz NOT NULL DEFAULT now(),
Expand Down
12 changes: 11 additions & 1 deletion backend/db/seed.sh
Original file line number Diff line number Diff line change
@@ -1,3 +1,13 @@
#!/usr/bin/env bash
set -euo pipefail

: "${DATABASE_URL:?Set DATABASE_URL before running this script}"

cd "$(dirname "$0")"
psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -f schema.sql -f seed.sql

psql "$DATABASE_URL" \
-X \
-v ON_ERROR_STOP=1 \
--single-transaction \
-f schema.sql \
-f seed.sql
35 changes: 33 additions & 2 deletions backend/db/seed.sql
Original file line number Diff line number Diff line change
@@ -1,2 +1,33 @@
TRUNCATE parts;
\copy parts FROM '../data/parts.csv' WITH (FORMAT csv, HEADER)
-- Load the CSV into a temporary table before updating existing parts
CREATE TEMP TABLE parts_import (LIKE parts INCLUDING DEFAULTS)
ON COMMIT DROP;


\copy parts_import (id, category, name, manufacturer, price_eur, image_path, specs) FROM '../data/parts.csv' WITH (FORMAT csv, HEADER)


INSERT INTO parts (
id,
category,
name,
manufacturer,
price_eur,
image_path,
specs
)
SELECT
id,
category,
name,
manufacturer,
price_eur,
image_path,
specs
FROM parts_import
ON CONFLICT (id) DO UPDATE SET
category = EXCLUDED.category,
name = EXCLUDED.name,
manufacturer = EXCLUDED.manufacturer,
price_eur = EXCLUDED.price_eur,
image_path = EXCLUDED.image_path,
specs = EXCLUDED.specs;
1 change: 0 additions & 1 deletion backend/server/.prettierrc

This file was deleted.

2 changes: 1 addition & 1 deletion backend/server/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 3 additions & 3 deletions backend/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@
"build": "tsc",
"start": "node dist/index.js",
"lint": "eslint .",
"format": "prettier --write .",
"format:check": "prettier --check ."
"format": "prettier --write . --ignore-path ../../.prettierignore",
"format:check": "prettier --check . --ignore-path ../../.prettierignore"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
Expand All @@ -23,7 +23,7 @@
"@types/pg": "^8.23.1",
"eslint": "^10.12.0",
"eslint-config-prettier": "^10.1.8",
"prettier": "^3.9.10",
"prettier": "3.9.10",
"tsx": "^4.23.15",
"typescript": "~6.0.0",
"typescript-eslint": "^8.71.2"
Expand Down
89 changes: 69 additions & 20 deletions backend/server/src/resolvers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -379,29 +379,56 @@ async function manufacturers(
return rows.map((row) => row.manufacturer);
}

async function publishBuild(_: unknown, { input }: {
input: { tittel: string; comment?: string | null; partIds: string[] };
}) {
async function publishBuild(
_: unknown,
{
input,
}: {
input: { tittel: string; comment?: string | null; partIds: string[] };
},
) {
const tittel = input.tittel.trim();
const comment = input.comment?.trim() || null;
const invalid = (message: string) => new GraphQLError(message, {
extensions: { code: "BAD_USER_INPUT" },
});
if (!tittel || [...tittel].length > 50) throw invalid("Enter a title of 1–50 characters.");
if (comment && [...comment].length > 200) throw invalid("Comment must be at most 200 characters.");
if (input.partIds.length < 1 || input.partIds.length > 8 || input.partIds.some(id => !UUID_RE.test(id))) {
const invalid = (message: string) =>
new GraphQLError(message, {
extensions: { code: "BAD_USER_INPUT" },
});
if (!tittel || [...tittel].length > 50)
throw invalid("Enter a title of 1–50 characters.");
if (comment && [...comment].length > 200)
throw invalid("Comment must be at most 200 characters.");
if (
input.partIds.length < 1 ||
input.partIds.length > 8 ||
input.partIds.some((id) => !UUID_RE.test(id))
) {
throw invalid("Choose between 1 and 8 valid components.");
}
const { rows: selected } = await pool.query<{ id: string; category: string }>(
"SELECT id, category FROM parts WHERE id = ANY($1::uuid[])", [input.partIds],
"SELECT id, category FROM parts WHERE id = ANY($1::uuid[])",
[input.partIds],
);
const categories = ["CPU", "GPU", "RAM", "Storage", "Motherboard", "PSU", "PCCase", "CPUCooler"];
if (selected.length !== input.partIds.length ||
new Set(selected.map(part => part.category)).size !== selected.length ||
selected.some(part => !categories.includes(part.category))) {
const categories = [
"CPU",
"GPU",
"RAM",
"Storage",
"Motherboard",
"PSU",
"PCCase",
"CPUCooler",
];
if (
selected.length !== input.partIds.length ||
new Set(selected.map((part) => part.category)).size !== selected.length ||
selected.some((part) => !categories.includes(part.category))
) {
throw invalid("Choose existing components, with only one per category.");
}
const ids = categories.map(category => selected.find(part => part.category === category)?.id ?? null);
const ids = categories.map(
(category) =>
selected.find((part) => part.category === category)?.id ?? null,
);
const { rows } = await pool.query(
`INSERT INTO builds (tittel, comment, cpu_id, gpu_id, ram_id, storage_id, motherboard_id, psu_id, case_id, cooler_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING *`,
Expand All @@ -421,9 +448,23 @@ export const resolvers = {
JSON: JSONScalar,
Mutation: { publishBuild },
Query: {
parts, part, manufacturers,
builds: async (_: unknown, { limit = 20, offset = 0 }: { limit?: number | null; offset?: number | null }) => {
if (limit == null || offset == null || limit < 1 || limit > 100 || offset < 0) {
parts,
part,
manufacturers,
builds: async (
_: unknown,
{
limit = 20,
offset = 0,
}: { limit?: number | null; offset?: number | null },
) => {
if (
limit == null ||
offset == null ||
limit < 1 ||
limit > 100 ||
offset < 0
) {
throw new GraphQLError("Invalid page size or offset", {
extensions: { code: "BAD_USER_INPUT" },
});
Expand All @@ -438,8 +479,16 @@ export const resolvers = {
Build: {
createdAt: (row: { created_at: Date }) => row.created_at.toISOString(),
parts: async (row: Record<string, unknown>) => {
const ids = [row.cpu_id, row.gpu_id, row.ram_id, row.storage_id,
row.motherboard_id, row.psu_id, row.case_id, row.cooler_id].filter(Boolean);
const ids = [
row.cpu_id,
row.gpu_id,
row.ram_id,
row.storage_id,
row.motherboard_id,
row.psu_id,
row.case_id,
row.cooler_id,
].filter(Boolean);
const { rows } = await pool.query<PartRow>(
`SELECT ${PART_COLUMNS} FROM parts WHERE id = ANY($1::uuid[]) ORDER BY category`,
[ids],
Expand Down
4 changes: 2 additions & 2 deletions backend/server/src/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,11 +89,11 @@ export const typeDefs = /* GraphQL */ `
tittel: String!
comment: String
partIds: [ID!]!
}
}
type Mutation {
publishBuild(input: PublishBuildInput!): Build!
}
}
type Query {
"""
Expand Down
Loading