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
228 changes: 160 additions & 68 deletions client/web/package-lock.json

Large diffs are not rendered by default.

8 changes: 6 additions & 2 deletions client/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -83,11 +83,15 @@
"typescript": "~5.9.3",
"typescript-eslint": "^8.46.4",
"vite": "^7.2.4",
"vite-plugin-pwa": "^1.2.0"
"vite-plugin-pwa": "^0.19.8"
},
"overrides": {
"postcss": "^8.4.38",
"semver": "^7.5.2",
"minimatch": "^10.2.1"
"minimatch": "^10.2.1",
"serialize-javascript": "^7.0.5",
"vite-plugin-pwa": {
"vite": "$vite"
}
}
}
3 changes: 3 additions & 0 deletions cmd/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,9 @@ func (app *application) mount() http.Handler {
r.Get("/hackathon-date-range", app.getHackathonDateRange)
r.Post("/hackathon-date-range", app.setHackathonDateRange)
r.Put("/scan-types", app.updateScanTypesHandler)
r.Get("/meal-groups", app.getMealGroups)
r.Put("/meal-groups", app.updateMealGroups)
r.Get("/meal-groups/stats", app.getMealGroupStats)
r.Put("/applications-enabled", app.setApplicationsEnabled)
})

Expand Down
54 changes: 52 additions & 2 deletions cmd/api/scans.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package main

import (
"context"
"errors"
"math/rand"
"net/http"

"github.com/go-chi/chi"
Expand Down Expand Up @@ -29,6 +31,11 @@ type UpdateScanTypesPayload struct {
ScanTypes []store.ScanType `json:"scan_types" validate:"required,dive"`
}

type CreateScanResponse struct {
*store.Scan
MealGroup *string `json:"meal_group"`
}

// getScanTypesHandler returns all configured scan types
//
// @Summary Get scan types (Admin)
Expand Down Expand Up @@ -61,7 +68,7 @@ func (app *application) getScanTypesHandler(w http.ResponseWriter, r *http.Reque
// @Accept json
// @Produce json
// @Param scan body CreateScanPayload true "Scan to create"
// @Success 201 {object} store.Scan
// @Success 201 {object} CreateScanResponse
// @Failure 400 {object} object{error=string}
// @Failure 401 {object} object{error=string}
// @Failure 403 {object} object{error=string}
Expand Down Expand Up @@ -148,7 +155,23 @@ func (app *application) createScanHandler(w http.ResponseWriter, r *http.Request
return
}

if err := app.jsonResponse(w, http.StatusCreated, scan); err != nil {
if found.Category == store.ScanCategoryCheckIn {
app.assignMealGroup(r.Context(), req.UserID)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think in the future we should separate meal scans and check in scans to different functions, might be helpful for implementing other things based on whether a person is checked in or not.

}

// Fetch meal group for response (non-fatal)
mealGroup, err := app.store.Application.GetMealGroupByUserID(r.Context(), req.UserID)
if err != nil && !errors.Is(err, store.ErrNotFound) {
// We don't want to fail the scan if we can't get the meal group info
app.logger.Warnw("failed to fetch meal group for scan response", "user_id", req.UserID, "error", err)
}

response := CreateScanResponse{
Scan: scan,
MealGroup: mealGroup,
}

if err := app.jsonResponse(w, http.StatusCreated, response); err != nil {
Comment thread
NoelVarghese2006 marked this conversation as resolved.
app.internalServerError(w, r, err)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

add a return here

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

actually its technically not needed, but include it for clarity

}
}
Expand Down Expand Up @@ -185,6 +208,33 @@ func (app *application) getUserScansHandler(w http.ResponseWriter, r *http.Reque
}
}

func (app *application) assignMealGroup(ctx context.Context, userID string) {
groups, err := app.store.Settings.GetMealGroups(ctx)
if err != nil {
app.logger.Warnw("failed to fetch meal groups for assignment", "error", err)
return
}

if len(groups) == 0 {
return
}

hackerApp, err := app.store.Application.GetByUserID(ctx, userID)
if err != nil {
app.logger.Warnw("failed to fetch application for meal group assignment", "user_id", userID, "error", err)
return
}

if hackerApp.MealGroup != nil {
return // Already assigned
}

selectedGroup := groups[rand.Intn(len(groups))]
if err := app.store.Application.SetMealGroup(ctx, hackerApp.ID, selectedGroup); err != nil {
app.logger.Warnw("failed to set meal group on application", "app_id", hackerApp.ID, "error", err)
}
}

// getScanStatsHandler returns aggregate scan counts grouped by scan type
//
// @Summary Get scan statistics (Admin)
Expand Down
56 changes: 56 additions & 0 deletions cmd/api/scans_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package main

import (
"encoding/json"
"errors"
"net/http"
"strings"
"testing"
Expand Down Expand Up @@ -56,8 +57,50 @@ func TestCreateScan(t *testing.T) {
app := newTestApplication(t)
mockSettings := app.store.Settings.(*store.MockSettingsStore)
mockScans := app.store.Scans.(*store.MockScansStore)
mockApps := app.store.Application.(*store.MockApplicationStore)

groups := []string{"A", "B"}
hackerApp := &store.Application{ID: "app-1", UserID: "user-1", MealGroup: nil}

mockSettings.On("GetScanTypes").Return(scanTypes, nil).Once()
mockSettings.On("GetMealGroups").Return(groups, nil).Once()
mockApps.On("GetByUserID", "user-1").Return(hackerApp, nil).Once()
mockApps.On("SetMealGroup", "app-1", mock.AnythingOfType("string")).Return(nil).Once()
mockApps.On("GetMealGroupByUserID", "user-1").Return(&groups[0], nil).Once()
mockScans.On("Create", mock.AnythingOfType("*store.Scan")).Return(nil).Once()

body := `{"user_id":"user-1","scan_type":"check_in"}`
req, err := http.NewRequest(http.MethodPost, "/", strings.NewReader(body))
require.NoError(t, err)
req.Header.Set("Content-Type", "application/json")
req = setUserContext(req, newAdminUser())

rr := executeRequest(req, http.HandlerFunc(app.createScanHandler))
checkResponseCode(t, http.StatusCreated, rr.Code)

var resp struct {
Data CreateScanResponse `json:"data"`
}
err = json.NewDecoder(rr.Body).Decode(&resp)
require.NoError(t, err)
assert.NotNil(t, resp.Data.MealGroup)
assert.Equal(t, groups[0], *resp.Data.MealGroup)

mockSettings.AssertExpectations(t)
mockScans.AssertExpectations(t)
mockApps.AssertExpectations(t)
})

t.Run("check_in success - meal group assignment failure is non-fatal", func(t *testing.T) {
app := newTestApplication(t)
mockSettings := app.store.Settings.(*store.MockSettingsStore)
mockScans := app.store.Scans.(*store.MockScansStore)
mockApps := app.store.Application.(*store.MockApplicationStore)

mockSettings.On("GetScanTypes").Return(scanTypes, nil).Once()
// Simulate error in meal group fetching
mockSettings.On("GetMealGroups").Return(nil, errors.New("db error")).Once()
mockApps.On("GetMealGroupByUserID", "user-1").Return(nil, store.ErrNotFound).Once()
mockScans.On("Create", mock.AnythingOfType("*store.Scan")).Return(nil).Once()

body := `{"user_id":"user-1","scan_type":"check_in"}`
Expand All @@ -69,18 +112,30 @@ func TestCreateScan(t *testing.T) {
rr := executeRequest(req, http.HandlerFunc(app.createScanHandler))
checkResponseCode(t, http.StatusCreated, rr.Code)

var resp struct {
Data CreateScanResponse `json:"data"`
}
err = json.NewDecoder(rr.Body).Decode(&resp)
require.NoError(t, err)
assert.Nil(t, resp.Data.MealGroup)

mockSettings.AssertExpectations(t)
mockScans.AssertExpectations(t)
mockApps.AssertExpectations(t)
})

t.Run("item scan when checked in", func(t *testing.T) {
app := newTestApplication(t)
mockSettings := app.store.Settings.(*store.MockSettingsStore)
mockScans := app.store.Scans.(*store.MockScansStore)
mockApps := app.store.Application.(*store.MockApplicationStore)

mealGroup := "A"

mockSettings.On("GetScanTypes").Return(scanTypes, nil).Once()
mockScans.On("HasCheckIn", "user-1", []string{"check_in"}).Return(true, nil).Once()
mockScans.On("Create", mock.AnythingOfType("*store.Scan")).Return(nil).Once()
mockApps.On("GetMealGroupByUserID", "user-1").Return(&mealGroup, nil).Once()

body := `{"user_id":"user-1","scan_type":"lunch"}`
req, err := http.NewRequest(http.MethodPost, "/", strings.NewReader(body))
Expand All @@ -93,6 +148,7 @@ func TestCreateScan(t *testing.T) {

mockSettings.AssertExpectations(t)
mockScans.AssertExpectations(t)
mockApps.AssertExpectations(t)
})

t.Run("403 not checked in", func(t *testing.T) {
Expand Down
107 changes: 107 additions & 0 deletions cmd/api/settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -423,6 +423,113 @@ func (app *application) setHackathonDateRange(w http.ResponseWriter, r *http.Req
}
}

type UpdateMealGroupsPayload struct {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

update meal groups requires at least one string, but we have a specific check for len(groups) == 0 in assignMealGroup. Do we want a case where there are no meal groups?

Groups []string `json:"groups" validate:"required,min=1,dive,required,min=1,max=50"`
}

type MealGroupsResponse struct {
Groups []string `json:"groups"`
}

type MealGroupStatsResponse struct {
Stats map[string]int `json:"stats"`
}

// getMealGroups returns the configured meal group names
//
// @Summary Get meal groups (Super Admin)
// @Description Returns the configured list of meal group names
// @Tags superadmin/settings
// @Produce json
// @Success 200 {object} MealGroupsResponse
// @Failure 401 {object} object{error=string}
// @Failure 403 {object} object{error=string}
// @Failure 500 {object} object{error=string}
// @Security CookieAuth
// @Router /superadmin/settings/meal-groups [get]
func (app *application) getMealGroups(w http.ResponseWriter, r *http.Request) {
groups, err := app.store.Settings.GetMealGroups(r.Context())
if err != nil {
app.internalServerError(w, r, err)
return
}

if err := app.jsonResponse(w, http.StatusOK, MealGroupsResponse{Groups: groups}); err != nil {
app.internalServerError(w, r, err)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

add return for clarity after internalServerError

}
}

// updateMealGroups replaces all meal group names
//
// @Summary Update meal groups (Super Admin)
// @Description Replaces the available meal group names with the provided array
// @Tags superadmin/settings
// @Accept json
// @Produce json
// @Param groups body UpdateMealGroupsPayload true "Groups to set"
// @Success 200 {object} MealGroupsResponse
// @Failure 400 {object} object{error=string}
// @Failure 401 {object} object{error=string}
// @Failure 403 {object} object{error=string}
// @Failure 500 {object} object{error=string}
// @Security CookieAuth
// @Router /superadmin/settings/meal-groups [put]
func (app *application) updateMealGroups(w http.ResponseWriter, r *http.Request) {
var req UpdateMealGroupsPayload
if err := readJSON(w, r, &req); err != nil {
app.badRequestResponse(w, r, err)
return
}

if err := Validate.Struct(req); err != nil {
app.badRequestResponse(w, r, err)
return
}

// Validate unique names
nameMap := make(map[string]bool)
for _, name := range req.Groups {
if nameMap[name] {
app.badRequestResponse(w, r, errors.New("duplicate meal group name: "+name))
return
}
nameMap[name] = true
}

if err := app.store.Settings.SetMealGroups(r.Context(), req.Groups); err != nil {
app.internalServerError(w, r, err)
return
}

if err := app.jsonResponse(w, http.StatusOK, MealGroupsResponse(req)); err != nil {
app.internalServerError(w, r, err)
}
}

// getMealGroupStats returns the number of hackers assigned to each meal group
//
// @Summary Get meal group stats (Super Admin)
// @Description Returns assignment counts for each configured meal group
// @Tags superadmin/settings
// @Produce json
// @Success 200 {object} MealGroupStatsResponse
// @Failure 401 {object} object{error=string}
// @Failure 403 {object} object{error=string}
// @Failure 500 {object} object{error=string}
// @Security CookieAuth
// @Router /superadmin/settings/meal-groups/stats [get]
func (app *application) getMealGroupStats(w http.ResponseWriter, r *http.Request) {
stats, err := app.store.Settings.GetMealGroupStats(r.Context())
if err != nil {
app.internalServerError(w, r, err)
return
}

if err := app.jsonResponse(w, http.StatusOK, MealGroupStatsResponse{Stats: stats}); err != nil {
app.internalServerError(w, r, err)
}
}

// getApplicationsEnabled returns whether applications are currently open
//
// @Summary Get applications enabled status
Expand Down
Loading
Loading