Skip to content
Open
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
29 changes: 29 additions & 0 deletions SOLUTION.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Environment

- I decided to create a Docker Compose setup to make development easier by reducing local configuration requirements

# AI

- I'm using Copilot integrated with VSCode to accelerate coding

# Back-end

- The CSV data is loaded when the server starts
- The reviews are grouped by UUID to make review requests less expensive
- The college names are stored in a list, removing duplications
- Includes a test for the load data function

## Front-end

- The components are specialized
- It is possible to use them in other contexts
- The SearchInput component has debouncing to avoid hitting the API unnecessarily
- I added a dummy element just for better appearance


## Run

```sh
docker compose -p challenge up -d --build
```

Binary file added back-end/.DS_Store
Binary file not shown.
1 change: 1 addition & 0 deletions back-end/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
/tmp
8 changes: 8 additions & 0 deletions back-end/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
FROM golang:1.24-alpine

WORKDIR /app
COPY . .

RUN apk update && apk add curl
RUN go mod tidy
RUN curl -sSfL https://raw.githubusercontent.com/air-verse/air/master/install.sh | sh -s -- -b $(go env GOPATH)/bin
Empty file added back-end/air.toml
Empty file.
13 changes: 10 additions & 3 deletions back-end/go.mod
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
module niche.com/fullstack-exercise

go 1.24
module niche.com/fullstack-exercise

go 1.24

require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/stretchr/testify v1.11.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
9 changes: 9 additions & 0 deletions back-end/go.sum
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
79 changes: 76 additions & 3 deletions back-end/reviews.go
Original file line number Diff line number Diff line change
@@ -1,13 +1,86 @@
package main

import (
"encoding/csv"
"fmt"
"os"
)

type Review struct {
CollegeUuid string `json:"collegeUuid"`
ReviewText string `json:"reviewText"`
}

type College struct {
Uuid string `json:"uuid"`
Name string `json:"name"`
Url string `json:"url"`
}

// ReviewsData holds the reviews data. You should modify this type.
type ReviewsData struct{}
type ReviewsData struct {
Reviews map[string][]Review `json:"reviews"`
Colleges []College `json:"colleges"`
}

func loadReviews() (*ReviewsData, error) {
// TODO: Implement this function to load reviews from the CSV file
// 1. Read the CSV file from data/niche_reviews.csv

file, err := os.Open("data/niche_reviews.csv")
if err != nil {
fmt.Println("Error opening CSV file:", err)
return nil, err
}
defer file.Close()

reader := csv.NewReader(file)

records, err := reader.ReadAll()
if err != nil {
fmt.Println("Error reading CSV file:", err)
return nil, err
}

// 2. Parse the CSV data to extract college names and reviews
// 3. Build and return a ReviewsData struct with the processed data
uniqColleges := make(map[string]bool, 0)
colleges := make([]College, 0)
reviews := make(map[string][]Review, 0)
for i, record := range records {
if i == 0 {
continue // Skip header row
}
uuid := record[0]
name := record[1]
url := record[2]
text := record[3]
group, ok := reviews[uuid]
if !ok {
group = make([]Review, 0)
group = append(group, Review{
CollegeUuid: uuid,
ReviewText: text,
})
} else {
group = append(group, Review{
CollegeUuid: uuid,
ReviewText: text,
})
}
reviews[uuid] = group
_, ok = uniqColleges[uuid]
if !ok {
uniqColleges[uuid] = true
colleges = append(colleges, College{Name: name, Uuid: uuid, Url: url})
}
}

return nil, nil
// 3. Build and return a ReviewsData struct with the processed data
return &ReviewsData{Reviews: reviews, Colleges: colleges}, nil
}

type ByCollegeName []College

func (a ByCollegeName) Len() int { return len(a) }
func (a ByCollegeName) Swap(i, j int) { a[i], a[j] = a[j], a[i] }
func (a ByCollegeName) Less(i, j int) bool { return a[i].Name < a[j].Name }
28 changes: 28 additions & 0 deletions back-end/reviews_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package main

import (
"testing"

"github.com/stretchr/testify/assert"
)

func TestLoadReviews(t *testing.T) {
data, err := loadReviews()

assert.NoError(t, err, "Expected no error loading reviews")
assert.NotNil(t, data, "Expected non-nil data")

t.Run("Reviews", func(t *testing.T) {
assert.Greater(t, len(data.Reviews), 0, "Expected at least one review group")
key := "515965b4-7557-44dd-a0b9-8832d2e143ee"
assert.Greater(t, len(data.Reviews[key]), 0, "Expected at least one review in the first group")
assert.Len(t, data.Reviews[key][0].CollegeUuid, 36, "Expected first review to have correct Uuid")
assert.Regexp(t, "^I am a dental student and feel like because the", data.Reviews[key][0].ReviewText, "Expected first review to have correct text")
})

t.Run("Colleges", func(t *testing.T) {
assert.Greater(t, len(data.Colleges), 0, "Expected at least one college")
assert.Equal(t, "A.T. Still University of Health Sciences", data.Colleges[0].Name, "Expected first college to have correct Name")
assert.Equal(t, "515965b4-7557-44dd-a0b9-8832d2e143ee", data.Colleges[0].Uuid, "Expected first college to have correct Uuid")
})
}
50 changes: 48 additions & 2 deletions back-end/server.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
package main

import (
"encoding/json"
"net/http"
"sort"
"strings"
)

// Server represents the HTTP server for our application
Expand All @@ -17,6 +20,13 @@ func NewServer(reviewsData *ReviewsData) *Server {
ReviewsData: reviewsData,
}

data, err := loadReviews()
if err != nil {
server.ReviewsData = &ReviewsData{Reviews: map[string][]Review{}}
}

server.ReviewsData = data

// Register routes
server.Router.HandleFunc("/autocomplete", server.handleAutocomplete)
server.Router.HandleFunc("/reviews", server.handleGetReviews)
Expand All @@ -34,8 +44,24 @@ func (s *Server) handleAutocomplete(w http.ResponseWriter, r *http.Request) {
// This should search through the colleges in ReviewsData
// and return matches based on the query string

query := r.URL.Query().Get("q")
matches := []College{}
for _, college := range s.ReviewsData.Colleges {
if strings.HasPrefix(strings.ToLower(college.Name), strings.ToLower(query)) {
matches = append(matches, college)
}
}

sort.Sort(ByCollegeName(matches))

response, err := json.Marshal(matches)
if err != nil {
w.Write([]byte(`[]`))
return
}

// Write a simple 200 OK with empty JSON response for now
w.Write([]byte("{}"))
w.Write(response)
}

// handleGetReviews handles the reviews endpoint
Expand All @@ -48,6 +74,26 @@ func (s *Server) handleGetReviews(w http.ResponseWriter, r *http.Request) {
// This should retrieve reviews for a specific college
// and return them in the response

collegeUuid := r.URL.Query().Get("college")

if collegeUuid == "" {
w.Write([]byte(`[]`))
return
}

reviews, ok := s.ReviewsData.Reviews[collegeUuid]

if !ok {
w.Write([]byte(`[]`))
return
}

response, err := json.Marshal(reviews)
if err != nil {
w.Write([]byte(`[]`))
return
}

// Write a simple 200 OK with empty JSON response for now
w.Write([]byte("{}"))
w.Write(response)
}
2 changes: 2 additions & 0 deletions back-end/start.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
#!/bin/sh
air -c air.toml
20 changes: 20 additions & 0 deletions docker-compose.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
services:
backend:
build: ./back-end
ports:
- "8080:8080"
volumes:
- ./back-end:/app
working_dir: /app
command: ./start.sh
frontend:
image: node:20
ports:
- "3000:3000"
volumes:
- ./front-end:/app
- node_modules:/app/node_modules
working_dir: /app
command: ./start.sh
volumes:
node_modules: {}
13 changes: 13 additions & 0 deletions front-end/.editorconfig
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# top-most EditorConfig file
root = true

[*]
indent_style = space
indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true

[*.md]
trim_trailing_whitespace = false
2 changes: 1 addition & 1 deletion front-end/next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { NextConfig } from "next";

const nextConfig: NextConfig = {
/* config options here */
devIndicators: false
devIndicators: false,
};

export default nextConfig;
Loading