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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
11 changes: 11 additions & 0 deletions backend/internal/discord/poll.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion backend/internal/postgres/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
4 changes: 4 additions & 0 deletions backend/internal/postgres/store_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
31 changes: 31 additions & 0 deletions backend/internal/web/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions backend/internal/web/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 22 additions & 0 deletions backend/internal/web/handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}
2 changes: 2 additions & 0 deletions web/e2e/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,8 @@ export async function mockSignedInLeader(page: Page): Promise<Recorder> {
{ 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:
Expand Down
20 changes: 20 additions & 0 deletions web/e2e/web.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
4 changes: 2 additions & 2 deletions web/package-lock.json

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

2 changes: 1 addition & 1 deletion web/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "raidy-web",
"private": true,
"version": "0.0.11",
"version": "0.0.12",
"type": "module",
"scripts": {
"dev": "vite",
Expand Down
8 changes: 7 additions & 1 deletion web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,13 @@ export default function App() {
) : !selected ? (
<NoServers inviteUrl={inviteUrl} />
) : tab === "overview" ? (
<Overview team={selected} revision={revision} onEditSchedule={() => setTab("schedule")} onError={setError} />
<Overview
team={selected}
currentUserID={user.id}
revision={revision}
onEditSchedule={() => setTab("schedule")}
onError={setError}
/>
) : tab === "schedule" ? (
<SchedulePanel
team={selected}
Expand Down
26 changes: 23 additions & 3 deletions web/src/features/overview/availability-grid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,14 +83,21 @@ export function AvailabilityGrid({
canManage,
busyID,
now = new Date(),
onAction
onAction,
currentUserID,
draft,
onToggle
}: {
poll: Poll;
members: Member[];
canManage: boolean;
busyID: number | null;
now?: Date;
onAction: (occurrence: Occurrence, action: OccurrenceAction) => void;
currentUserID?: string;
/** Dates the current user marks available while editing; null when not editing. */
draft?: Set<number> | null;
onToggle?: (occurrenceID: number) => void;
}) {
const occurrences = poll.occurrences;
const memberIDs = Array.from(
Expand All @@ -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";

Expand Down Expand Up @@ -136,11 +143,24 @@ export function AvailabilityGrid({
<span className="flex items-center gap-2.5">
<Avatar member={member} />
<span className="max-w-24 truncate sm:max-w-none">{member.name}</span>
{member.id === currentUserID && <span className="text-xs text-muted-foreground">You</span>}
</span>
</th>
{occurrences.map((occurrence) => (
<td key={occurrence.id} className="px-2 py-2 text-center">
<VoteCell vote={vote(occurrence, member.id)} name={member.name} />
{draft && member.id === currentUserID && occurrence.id && new Date(occurrence.startsAt) > now ? (
<button
type="button"
aria-pressed={draft.has(occurrence.id)}
aria-label={`Available on ${formatDay(occurrence.startsAt, poll.timezone)}`}
className="rounded ring-2 ring-primary/40 focus-visible:ring-primary"
onClick={() => onToggle?.(occurrence.id!)}
>
<VoteCell vote={draft.has(occurrence.id) ? "available" : "unavailable"} name={member.name} />
</button>
) : (
<VoteCell vote={vote(occurrence, member.id)} name={member.name} />
)}
</td>
))}
</tr>
Expand Down
84 changes: 79 additions & 5 deletions web/src/features/overview/overview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,13 @@ const actionMessages: Record<OccurrenceAction, string> = {

export function Overview({
team,
currentUserID,
revision,
onEditSchedule,
onError
}: {
team: Team;
currentUserID: string;
revision: number;
onEditSchedule: () => void;
onError: (value: string) => void;
Expand All @@ -30,6 +32,8 @@ export function Overview({
const [loading, setLoading] = useState(true);
const [reload, setReload] = useState(0);
const [busyID, setBusyID] = useState<number | null>(null);
const [draft, setDraft] = useState<Set<number> | null>(null);
const [savingVotes, setSavingVotes] = useState(false);

useEffect(() => {
let active = true;
Expand Down Expand Up @@ -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 <LoadingPanel />;
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 (
Expand Down Expand Up @@ -111,7 +155,13 @@ export function Overview({
}
actions={
polls.length > 1 && (
<Select value={poll.id.toString()} onValueChange={(value) => setPollID(Number(value))}>
<Select
value={poll.id.toString()}
onValueChange={(value) => {
setPollID(Number(value));
setDraft(null);
}}
>
<SelectTrigger className="h-8 w-44" aria-label="Voting period">
<SelectValue />
</SelectTrigger>
Expand All @@ -126,16 +176,40 @@ export function Overview({
)
}
/>
{canVote && (
<div className="mb-3 flex flex-wrap items-center justify-between gap-3">
<p className="text-sm text-muted-foreground">
{draft ? "Tap each date you can make, then save." : "Your answers are in the first row."}
</p>
{draft ? (
<div className="flex gap-2">
<Button variant="ghost" size="sm" disabled={savingVotes} onClick={() => setDraft(null)}>
Cancel
</Button>
<Button size="sm" disabled={savingVotes} onClick={() => saveVotes(poll)}>
{savingVotes ? "Saving…" : "Save availability"}
</Button>
</div>
) : (
<Button variant="outline" size="sm" onClick={() => startEditing(poll)}>
Edit my availability
</Button>
)}
</div>
)}
<AvailabilityGrid
poll={poll}
members={team.members}
canManage={team.isLeader}
canManage={team.isLeader && !draft}
busyID={busyID}
onAction={changeOccurrence}
currentUserID={currentUserID}
draft={draft}
onToggle={toggle}
/>
<p className="mt-3 text-xs text-muted-foreground">
Members answer in Discord. {team.isLeader ? "Use ⋯ under a date to confirm, cancel, or reopen it." : ""}
</p>
{team.isLeader && !draft && (
<p className="mt-3 text-xs text-muted-foreground">Use ⋯ under a date to confirm, cancel, or reopen it.</p>
)}
</>
);
}
Loading