diff --git a/README.md b/README.md index 7fc918f..ea9229b 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Raidy is a Discord-first raid scheduling project. One Go process runs the Discord bot, web API, scheduler, and production web assets, backed by PostgreSQL. -**Status:** `v0.0.11` is an early development snapshot. +**Status:** `v0.0.12` is an early development snapshot. ```text backend/ Go service, Discord interactions, web API, PostgreSQL migrations @@ -24,7 +24,7 @@ deployment/ local PostgreSQL and Kubernetes deployment manifests - Leaders confirm, cancel, or reopen dates. A confirmed date becomes `Attention required` when availability drops. - Polls become read-only history when their final occurrence starts. - The standalone web console manages teams and recurring schedules through Discord OAuth2. -- Members can view the current timetable on the web; voting and date decisions remain in Discord. +- Members vote on the web availability grid and leaders confirm, cancel, or reopen dates there; Discord votes still work during the move to announcement-only Discord. - The responsive console supports persisted light and dark themes. ## Run locally diff --git a/backend/internal/discord/poll.go b/backend/internal/discord/poll.go index 0e04d6d..f5d7064 100644 --- a/backend/internal/discord/poll.go +++ b/backend/internal/discord/poll.go @@ -213,6 +213,17 @@ func (b *Bot) saveAvailability(ctx context.Context, i *discordgo.Interaction, po return nil } +// SubmitAvailability records a member's answers made outside Discord: listed +// dates become Available and every other upcoming date Unavailable. The +// Discord announcement is refreshed in the background. +func (b *Bot) SubmitAvailability(ctx context.Context, pollID int64, memberID string, available []int64) error { + if err := b.Store.SetAvailability(ctx, pollID, memberID, available, now()); err != nil { + return err + } + b.refresher.queue(pollID) + return nil +} + func (b *Bot) openPollManage(ctx context.Context, i *discordgo.Interaction, pollID int64) error { if err := invalidID(pollID); err != nil { return err diff --git a/backend/internal/postgres/store.go b/backend/internal/postgres/store.go index 9eaad56..0376745 100644 --- a/backend/internal/postgres/store.go +++ b/backend/internal/postgres/store.go @@ -655,7 +655,7 @@ func (s *Store) SetAvailability(ctx context.Context, pollID int64, memberID stri wanted := make(map[int64]bool, len(availableIDs)) for _, id := range availableIDs { if !valid[id] { - return errors.New("invalid or expired occurrence") + return ValidationError{"invalid or expired occurrence"} } wanted[id] = true } diff --git a/backend/internal/postgres/store_test.go b/backend/internal/postgres/store_test.go index 5901cbf..e75a188 100644 --- a/backend/internal/postgres/store_test.go +++ b/backend/internal/postgres/store_test.go @@ -344,6 +344,10 @@ func TestSchedulingLifecycle(t *testing.T) { if err := store.SetAvailability(ctx, poll.ID, "outsider", nil, now); !errors.Is(err, ErrForbidden) { t.Fatalf("outsider availability error = %v", err) } + var validation ValidationError + if err := store.SetAvailability(ctx, poll.ID, "a", []int64{999999}, now); !errors.As(err, &validation) { + t.Fatalf("foreign occurrence availability error = %v, want ValidationError", err) + } if err := store.SetOccurrenceStatus(ctx, occurrence.ID, "outsider", "confirm", now); !errors.Is(err, ErrForbidden) { t.Fatalf("outsider status error = %v", err) } diff --git a/backend/internal/web/api.go b/backend/internal/web/api.go index 358f51d..66aa459 100644 --- a/backend/internal/web/api.go +++ b/backend/internal/web/api.go @@ -597,6 +597,37 @@ func pollJSON(view team.PollView) pollResponse { return result } +func (h *Handler) availability(w http.ResponseWriter, r *http.Request) { + pollID, err := strconv.ParseInt(r.PathValue("pollID"), 10, 64) + if err != nil || pollID <= 0 { + h.fail(w, r, clientError{message: "invalid timetable"}) + return + } + var input struct { + Available *[]int64 `json:"available"` + } + if err := decode(r, &input); err != nil { + h.fail(w, r, clientError{err.Error()}) + return + } + if input.Available == nil { + h.fail(w, r, clientError{message: "available must list the dates you can attend"}) + return + } + for _, id := range *input.Available { + if id <= 0 { + h.fail(w, r, clientError{message: "invalid raid date"}) + return + } + } + // SetAvailability only accepts members of this poll's roster snapshot. + if err := h.bot.SubmitAvailability(r.Context(), pollID, sessionFrom(r.Context()).User.ID, *input.Available); err != nil { + h.fail(w, r, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + func (h *Handler) occurrenceStatus(w http.ResponseWriter, r *http.Request) { id, err := strconv.ParseInt(r.PathValue("occurrenceID"), 10, 64) if err != nil || id <= 0 { diff --git a/backend/internal/web/handler.go b/backend/internal/web/handler.go index 33b696b..3abe7c0 100644 --- a/backend/internal/web/handler.go +++ b/backend/internal/web/handler.go @@ -93,6 +93,7 @@ func (h *Handler) Routes(health http.Handler) http.Handler { api.HandleFunc("POST /api/teams/{teamID}/republish", h.republish) api.HandleFunc("GET /api/teams/{teamID}/polls", h.openPolls) api.HandleFunc("POST /api/occurrences/{occurrenceID}/status", h.occurrenceStatus) + api.HandleFunc("PUT /api/polls/{pollID}/availability", h.availability) root.Handle("/api/", h.requireSession(api)) root.HandleFunc("/", h.serveApp) return root diff --git a/backend/internal/web/handler_test.go b/backend/internal/web/handler_test.go index f1db65a..ec528d0 100644 --- a/backend/internal/web/handler_test.go +++ b/backend/internal/web/handler_test.go @@ -128,3 +128,25 @@ func TestAppConfigExposesBotInviteWithoutSession(t *testing.T) { t.Fatalf("config = %d %s", response.Code, response.Body.String()) } } + +func TestAvailabilityRejectsInvalidInput(t *testing.T) { + h := &Handler{log: slog.New(slog.NewTextHandler(io.Discard, nil))} + tests := []struct{ id, body string }{ + {"0", `{"available":[1]}`}, + {"abc", `{"available":[1]}`}, + {"7", `{"available":[0]}`}, + {"7", `{"available":[1],"extra":true}`}, + {"7", `{}`}, + } + for _, test := range tests { + request := httptest.NewRequest(http.MethodPut, "/api/polls/"+test.id+"/availability", strings.NewReader(test.body)) + request.Header.Set("Content-Type", "application/json") + request.SetPathValue("pollID", test.id) + request = request.WithContext(context.WithValue(request.Context(), sessionKey{}, session{User: user{ID: "member"}})) + response := httptest.NewRecorder() + h.availability(response, request) + if response.Code != http.StatusBadRequest { + t.Errorf("id=%s body=%s status = %d, want 400", test.id, test.body, response.Code) + } + } +} diff --git a/web/e2e/fixtures.ts b/web/e2e/fixtures.ts index 8fbfbe9..9d530b7 100644 --- a/web/e2e/fixtures.ts +++ b/web/e2e/fixtures.ts @@ -104,6 +104,8 @@ export async function mockSignedInLeader(page: Page): Promise { { id: "c1", name: "raid-schedule" }, { id: "c2", name: "general" } ]); + case "PUT /api/polls/10/availability": + return route.fulfill({ status: 204 }); case "POST /api/occurrences/102/status": return route.fulfill({ status: 204 }); default: diff --git a/web/e2e/web.spec.ts b/web/e2e/web.spec.ts index 0b12b2c..ae55aaa 100644 --- a/web/e2e/web.spec.ts +++ b/web/e2e/web.spec.ts @@ -90,3 +90,23 @@ test("raid times use a 24-hour clock", async ({ page }) => { await expect(header).toContainText("21:00"); await expect(header).not.toContainText(/AM|PM/); }); + +test("a member edits and saves their availability on the web", async ({ page }) => { + const recorder = await mockSignedInLeader(page); + await page.goto("/app"); + const grid = page.getByRole("table", { name: /availability/i }); + // The signed-in member's row comes first and is marked as theirs. + await expect(grid.getByRole("rowheader").first()).toContainText("Alisaie"); + await expect(grid.getByRole("rowheader").first()).toContainText("You"); + + await page.getByRole("button", { name: "Edit my availability" }).click(); + const thursday = page.getByRole("button", { name: "Available on Thu 8" }); + await expect(thursday).toHaveAttribute("aria-pressed", "true"); + await thursday.click(); + await expect(thursday).toHaveAttribute("aria-pressed", "false"); + await page.getByRole("button", { name: "Save availability" }).click(); + + await expect(page.getByText("Availability saved")).toBeVisible(); + expect(recorder.bodies["PUT /api/polls/10/availability"]).toEqual({ available: [101, 103] }); + await expect(page.getByRole("button", { name: "Edit my availability" })).toBeVisible(); +}); diff --git a/web/package-lock.json b/web/package-lock.json index f8ebe1a..75163c3 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,12 +1,12 @@ { "name": "raidy-web", - "version": "0.0.11", + "version": "0.0.12", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "raidy-web", - "version": "0.0.11", + "version": "0.0.12", "dependencies": { "@fontsource-variable/geist": "^5.3.0", "@fontsource-variable/geist-mono": "^5.3.0", diff --git a/web/package.json b/web/package.json index b684ec7..1499586 100644 --- a/web/package.json +++ b/web/package.json @@ -1,7 +1,7 @@ { "name": "raidy-web", "private": true, - "version": "0.0.11", + "version": "0.0.12", "type": "module", "scripts": { "dev": "vite", diff --git a/web/src/App.tsx b/web/src/App.tsx index b826f94..391f8b8 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -192,7 +192,13 @@ export default function App() { ) : !selected ? ( ) : tab === "overview" ? ( - setTab("schedule")} onError={setError} /> + setTab("schedule")} + onError={setError} + /> ) : tab === "schedule" ? ( void; + currentUserID?: string; + /** Dates the current user marks available while editing; null when not editing. */ + draft?: Set | null; + onToggle?: (occurrenceID: number) => void; }) { const occurrences = poll.occurrences; const memberIDs = Array.from( @@ -99,7 +106,7 @@ export function AvailabilityGrid({ const name = (id: string) => members.find((member) => member.id === id)?.name ?? "Former member"; const rows = memberIDs .map((id) => members.find((member) => member.id === id) ?? { id, name: name(id) }) - .sort((a, b) => a.name.localeCompare(b.name)); + .sort((a, b) => (a.id === currentUserID ? -1 : b.id === currentUserID ? 1 : a.name.localeCompare(b.name))); const vote = (occurrence: Occurrence, id: string): Vote => occurrence.votes?.find((value) => value.memberId === id)?.status ?? "pending"; @@ -136,11 +143,24 @@ export function AvailabilityGrid({ {member.name} + {member.id === currentUserID && You} {occurrences.map((occurrence) => ( - + {draft && member.id === currentUserID && occurrence.id && new Date(occurrence.startsAt) > now ? ( + + ) : ( + + )} ))} diff --git a/web/src/features/overview/overview.tsx b/web/src/features/overview/overview.tsx index ccdbf7f..7fe314c 100644 --- a/web/src/features/overview/overview.tsx +++ b/web/src/features/overview/overview.tsx @@ -15,11 +15,13 @@ const actionMessages: Record = { export function Overview({ team, + currentUserID, revision, onEditSchedule, onError }: { team: Team; + currentUserID: string; revision: number; onEditSchedule: () => void; onError: (value: string) => void; @@ -30,6 +32,8 @@ export function Overview({ const [loading, setLoading] = useState(true); const [reload, setReload] = useState(0); const [busyID, setBusyID] = useState(null); + const [draft, setDraft] = useState | null>(null); + const [savingVotes, setSavingVotes] = useState(false); useEffect(() => { let active = true; @@ -63,8 +67,48 @@ export function Overview({ } } + function startEditing(current: Poll) { + const mine = current.occurrences.filter( + (occurrence) => + occurrence.id && + occurrence.votes?.some((vote) => vote.memberId === currentUserID && vote.status === "available") + ); + setDraft(new Set(mine.map((occurrence) => occurrence.id!))); + } + + function toggle(occurrenceID: number) { + setDraft((current) => { + const next = new Set(current); + if (next.has(occurrenceID)) next.delete(occurrenceID); + else next.add(occurrenceID); + return next; + }); + } + + async function saveVotes(current: Poll) { + if (!draft) return; + setSavingVotes(true); + try { + await api(`/api/polls/${current.id}/availability`, { + method: "PUT", + body: JSON.stringify({ available: [...draft] }) + }); + toast.success("Availability saved"); + setDraft(null); + setReload((value) => value + 1); + } catch (reason) { + toast.error(message(reason)); + } finally { + setSavingVotes(false); + } + } + if (loading) return ; const poll = polls.find((value) => value.id === pollID) ?? null; + const canVote = + poll !== null && + poll.occurrences.some((occurrence) => occurrence.votes?.some((vote) => vote.memberId === currentUserID)) && + poll.occurrences.some((occurrence) => new Date(occurrence.startsAt) > new Date()); if (!schedule) { return ( @@ -111,7 +155,13 @@ export function Overview({ } actions={ polls.length > 1 && ( - { + setPollID(Number(value)); + setDraft(null); + }} + > @@ -126,16 +176,40 @@ export function Overview({ ) } /> + {canVote && ( +
+

+ {draft ? "Tap each date you can make, then save." : "Your answers are in the first row."} +

+ {draft ? ( +
+ + +
+ ) : ( + + )} +
+ )} -

- Members answer in Discord. {team.isLeader ? "Use ⋯ under a date to confirm, cancel, or reopen it." : ""} -

+ {team.isLeader && !draft && ( +

Use ⋯ under a date to confirm, cancel, or reopen it.

+ )} ); }