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
228 changes: 206 additions & 22 deletions backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,116 @@ function readPaging(args: { limit?: number; offset?: number }) {
// Flere enn ca. 300 prikker blir uleselig og tungt å tegne.
const TASTE_MAP_MAX = 300;

/**
* ORDER BY for hver sortering i vinkatalogen.
* Klienten sender bare navnet (enum WineSort), og SQL-en hentes herfra,
* så brukerinput havner aldri direkte i spørringen.
* w.id til slutt gir stabil rekkefølge ved like verdier, så paginering
* ikke hopper over eller gjentar viner. Viner uten verdi havner sist.
*/
const WINE_ORDER = {
NAME_ASC: 'w.name ASC, w.id',
NAME_DESC: 'w.name DESC, w.id',
// Ved lik rating kommer vinene flest har vurdert først
RATING_DESC: 'w.avg_rating DESC NULLS LAST, w.rating_count DESC, w.id',
RATING_ASC: 'w.avg_rating ASC NULLS LAST, w.rating_count DESC, w.id',
ABV_DESC: 'w.abv DESC NULLS LAST, w.id',
ABV_ASC: 'w.abv ASC NULLS LAST, w.id',
} as const;

type WineSort = keyof typeof WINE_ORDER;

// Samme verdier som typene body_level og acidity_level i databasen.
// Ukjente verdier avvises her, ellers gir PostgreSQL en intern feil.
const BODY_LEVELS = [
'Very light-bodied',
'Light-bodied',
'Medium-bodied',
'Full-bodied',
'Very full-bodied',
];
const ACIDITY_LEVELS = ['Low', 'Medium', 'High'];

type WineFilter = {
type?: string | null;
country?: string | null;
region?: string | null;
grape?: string | null;
body?: string | null;
acidity?: string | null;
minRatingCount?: number | null;
};

function badInput(message: string) {
return new GraphQLError(message, { extensions: { code: 'BAD_USER_INPUT' } });
}

/**
* Lager WHERE-delen for vinkatalogen ut fra søk og filtre.
* Samme mønster som tasteWhere: verdiene sendes alltid som parametere.
* Både vinlisten og tellingen bruker denne, så totalCount stemmer med listen.
* Spørringen må ha w (wines) og wi (wineries) med, fordi søket ser på produsent.
*/
function catalogWhere(search: string | null, filter: WineFilter = {}) {
const where: string[] = [];
const params: unknown[] = [];
const add = (sql: string, value: unknown) => {
params.push(value);
where.push(sql.replaceAll('?', `$${params.length}`));
};

if (search) {
// Treff i starten av et ord i vinnavnet eller produsentnavnet
add(
`(
w.name ILIKE ?::text || '%'
OR w.name ILIKE '% ' || ?::text || '%'
OR wi.name ILIKE ?::text || '%'
OR wi.name ILIKE '% ' || ?::text || '%'
)`,
search,
);
}
if (filter.type) add('w.type = ?', filter.type);
// Landkoden ligger på wines, så landfilteret trenger ingen join
if (filter.country) add('w.country_code = ?', filter.country);
if (filter.region) {
const regionId = Number(filter.region);
if (!Number.isInteger(regionId)) throw badInput('region must be an id');
add('w.region_id = ?', regionId);
}
if (filter.grape) {
add(
`EXISTS (
SELECT 1 FROM wine_grapes wg
JOIN grapes g ON g.id = wg.grape_id
WHERE wg.wine_id = w.id AND g.name = ?
)`,
filter.grape,
);
}
if (filter.body) {
if (!BODY_LEVELS.includes(filter.body)) throw badInput('unknown body');
add('w.body = ?', filter.body);
}
if (filter.acidity) {
if (!ACIDITY_LEVELS.includes(filter.acidity)) {
throw badInput('unknown acidity');
}
add('w.acidity = ?', filter.acidity);
}
// Gjør sortering på rating nyttig: uten dette kommer viner med
// én enkelt vurdering på 5.0 først.
if (filter.minRatingCount != null) {
if (!Number.isInteger(filter.minRatingCount) || filter.minRatingCount < 0) {
throw badInput('minRatingCount cannot be negative');
}
add('w.rating_count >= ?', filter.minRatingCount);
}

return { sql: where.join(' AND ') || 'TRUE', params };
}

type TasteFilter = {
type?: string | null;
grape?: string | null;
Expand Down Expand Up @@ -115,6 +225,47 @@ const typeDefs = `#graphql
wineCount: Int!
}
# Sortering i vinkatalogen
enum WineSort {
NAME_ASC
NAME_DESC
RATING_DESC
RATING_ASC
ABV_DESC
ABV_ASC
}
# Filtre for vinkatalogen. Alle er valgfrie; det som mangler, filtreres ikke på.
input WineFilter {
type: String
# Landkode, f.eks. "FR"
country: String
region: ID
grape: String
body: String
acidity: String
# Bare viner med minst så mange vurderinger
minRatingCount: Int
}
type Country {
code: String!
name: String!
}
type Region {
id: ID!
name: String!
}
# Verdiene til nedtrekksmenyene i filterpanelet i vinkatalogen.
# Fylde og syre har faste verdier og ligger i klienten.
type CatalogOptions {
types: [String!]!
countries: [Country!]!
grapes: [String!]!
}
# Filtre for Explore by taste. Alle er valgfrie; det som mangler, filtreres ikke på.
input TasteFilter {
type: String
Expand All @@ -138,10 +289,17 @@ const typeDefs = `#graphql
wines(
search: String
filter: WineFilter
sort: WineSort = NAME_ASC
limit: Int = 20
offset: Int = 0
): WinePage!
catalogOptions: CatalogOptions!
# Regionene i ett land, til regionfilteret
regions(country: String!): [Region!]!
wine(id: ID!): WineDetails
foodCategories: [FoodCategory!]!
Expand All @@ -165,57 +323,54 @@ const resolvers = {

wines: async (
_: unknown,
args: { search?: string; limit?: number; offset?: number },
args: {
search?: string;
filter?: WineFilter | null;
sort?: WineSort | null;
limit?: number;
offset?: number;
},
) => {
const { limit, offset } = readPaging(args);
const search = args.search?.trim() || null;
const { sql, params } = catalogWhere(search, args.filter ?? {});
// GraphQL godtar bare verdiene i enum WineSort, så oppslaget finner alltid noe
const orderBy = WINE_ORDER[args.sort ?? 'NAME_ASC'];

const winesResult = await pool.query(
`
SELECT
w.id,
w.name,
w.type,
w.abv,
w.abv::float AS abv,
w.body,
w.acidity,
wi.name AS winery,
r.name AS region,
c.name AS country,
w.avg_rating AS "avgRating",
w.avg_rating::float AS "avgRating",
w.rating_count AS "ratingCount"
FROM wines w
JOIN wineries wi ON w.winery_id = wi.id
JOIN regions r ON w.region_id = r.id
JOIN countries c ON w.country_code = c.code
WHERE (
$1::text IS NULL
OR w.name ILIKE $1 || '%'
OR w.name ILIKE '% ' || $1 || '%'
OR wi.name ILIKE $1 || '%'
OR wi.name ILIKE '% ' || $1 || '%'
)
ORDER BY w.name, w.id
LIMIT $2
OFFSET $3
WHERE ${sql}
ORDER BY ${orderBy}
LIMIT $${params.length + 1}
OFFSET $${params.length + 2}
`,
[search, limit, offset],
[...params, limit, offset],
);

const countResult = await pool.query(
`
SELECT COUNT(*)
FROM wines w
JOIN wineries wi ON w.winery_id = wi.id
WHERE (
$1::text IS NULL
OR w.name ILIKE $1 || '%'
OR w.name ILIKE '% ' || $1 || '%'
OR wi.name ILIKE $1 || '%'
OR wi.name ILIKE '% ' || $1 || '%'
)
WHERE ${sql}
`,
[search],
params,
);

return {
Expand All @@ -224,6 +379,35 @@ const resolvers = {
};
},

catalogOptions: async () => {
// Alle druer som minst én vin har, ikke bare de vanligste som på
// Explore by taste, så filteret dekker hele datasettet.
const [types, countries, grapes] = await Promise.all([
pool.query(`SELECT DISTINCT type FROM wines ORDER BY type`),
pool.query(`SELECT code, name FROM countries ORDER BY name`),
pool.query(`
SELECT g.name
FROM grapes g
WHERE EXISTS (SELECT 1 FROM wine_grapes wg WHERE wg.grape_id = g.id)
ORDER BY g.name
`),
]);

return {
types: types.rows.map((r) => r.type),
countries: countries.rows,
grapes: grapes.rows.map((r) => r.name),
};
},

regions: async (_: unknown, args: { country: string }) => {
const result = await pool.query(
`SELECT id, name FROM regions WHERE country_code = $1 ORDER BY name`,
[args.country],
);
return result.rows;
},

wine: async (_: unknown, args: { id: string }) => {
const wineId = Number(args.id);

Expand Down
44 changes: 44 additions & 0 deletions client/src/components/SortSelect.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import type { WineSort } from '../types/wine';

const SORT_OPTIONS: { value: WineSort; label: string }[] = [
{ value: 'NAME_ASC', label: 'Name (A–Z)' },
{ value: 'NAME_DESC', label: 'Name (Z–A)' },
{ value: 'RATING_DESC', label: 'Rating (highest first)' },
{ value: 'RATING_ASC', label: 'Rating (lowest first)' },
{ value: 'ABV_DESC', label: 'Alcohol (highest first)' },
{ value: 'ABV_ASC', label: 'Alcohol (lowest first)' },
];

type Props = {
value: WineSort;
onChange: (sort: WineSort) => void;
};

/**
* Dropdown for choosing the sort order of the wine catalog.
*
* Controlled component, like TasteFilters: the value lives in WineCatalog,
* which sends it to the backend. The sorting itself happens in the database,
* so it applies to all wines, not just the page that is shown.
*
* A native <select> with a <label> works with keyboard and screen readers
* without extra ARIA.
*/
export default function SortSelect({ value, onChange }: Props) {
return (
<div className="catalog-field">
<label htmlFor="wine-sort">Sort by</label>
<select
id="wine-sort"
value={value}
onChange={(event) => onChange(event.target.value as WineSort)}
>
{SORT_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
);
}
Loading