diff --git a/app/plus/api.ts b/app/plus/api.ts
index 713f42588..1b5ecdf1f 100644
--- a/app/plus/api.ts
+++ b/app/plus/api.ts
@@ -1,6 +1,7 @@
import { createRouter } from "pages/api/trpc/[trpc]";
import { throwIfNotLoggedIn } from "utils/api";
import { suggestionFullSchema } from "utils/validators/suggestion";
+import { votesSchema } from "utils/validators/votes";
import { vouchSchema } from "utils/validators/vouch";
import service from "./service";
@@ -15,10 +16,10 @@ const plusApi = createRouter()
return service.getPlusStatuses();
},
})
- .query("ballots", {
+ .query("usersForVoting", {
resolve({ ctx }) {
const user = throwIfNotLoggedIn(ctx.user);
- return service.getBallots(user.id);
+ return service.getUsersForVoting(user.id);
},
})
.mutation("suggestion", {
@@ -34,5 +35,12 @@ const plusApi = createRouter()
const user = throwIfNotLoggedIn(ctx.user);
return service.addVouch({ input, userId: user.id });
},
+ })
+ .mutation("vote", {
+ input: votesSchema,
+ resolve({ input, ctx }) {
+ const user = throwIfNotLoggedIn(ctx.user);
+ return service.addVotes({ input, userId: user.id });
+ },
});
export default plusApi;
diff --git a/app/plus/components/PlusVotingButton.tsx b/app/plus/components/PlusVotingButton.tsx
new file mode 100644
index 000000000..8acbd71d0
--- /dev/null
+++ b/app/plus/components/PlusVotingButton.tsx
@@ -0,0 +1,23 @@
+import { Button } from "@chakra-ui/button";
+
+export function PlusVotingButton({
+ number,
+ onClick,
+}: {
+ number: -2 | -1 | 1 | 2;
+ onClick: () => void;
+}) {
+ return (
+
+ );
+}
diff --git a/app/plus/components/PlusVotingPage.tsx b/app/plus/components/PlusVotingPage.tsx
index 425c90a78..90cffc322 100644
--- a/app/plus/components/PlusVotingPage.tsx
+++ b/app/plus/components/PlusVotingPage.tsx
@@ -1,12 +1,103 @@
+import { Button } from "@chakra-ui/button";
+import { Box, HStack } from "@chakra-ui/layout";
+import { Progress } from "@chakra-ui/progress";
+import Markdown from "components/common/Markdown";
+import SubText from "components/common/SubText";
+import UserAvatar from "components/common/UserAvatar";
+import { useEffect } from "react";
+import { getFullUsername } from "utils/strings";
import usePlusVoting from "../hooks/usePlusVoting";
+import { PlusVotingButton } from "./PlusVotingButton";
export default function PlusVotingPage() {
- const { isLoading, usersToVoteOn } = usePlusVoting();
+ const {
+ isLoading,
+ shouldRedirect,
+ plusStatus,
+ currentUser,
+ handleVote,
+ progress,
+ previousUser,
+ goBack,
+ } = usePlusVoting();
+
+ useEffect(() => {
+ //redirect!!
+ }, [shouldRedirect]);
+
+ if (isLoading || !plusStatus || !currentUser) return null;
- if (isLoading) return null;
return (
-
- {JSON.stringify(usersToVoteOn, null, 2)}
-
+
+ {previousUser && (
+
+
+
+ {getFullUsername(previousUser)}
+
+
+
+ )}
+
+
+
+
+ {getFullUsername(currentUser)}
+
+
+
+ {currentUser.region === plusStatus.region && (
+
+ handleVote({ userId: currentUser.userId, score: -2 })
+ }
+ />
+ )}
+ handleVote({ userId: currentUser.userId, score: -1 })}
+ />
+ handleVote({ userId: currentUser.userId, score: 1 })}
+ />
+ {currentUser.region === plusStatus.region && (
+ handleVote({ userId: currentUser.userId, score: 2 })}
+ />
+ )}
+
+ {currentUser.suggestions && (
+
+ Suggestions
+ {currentUser.suggestions.map((suggestion) => {
+ return (
+
+ "{suggestion.description}" -{" "}
+ {getFullUsername(suggestion.suggesterUser)}
+
+ );
+ })}
+
+ )}
+ {currentUser.bio && (
+
+ Bio
+
+
+ )}
+
);
}
diff --git a/app/plus/hooks/usePlusVoting.ts b/app/plus/hooks/usePlusVoting.ts
index f84d17d07..c5b7e4c98 100644
--- a/app/plus/hooks/usePlusVoting.ts
+++ b/app/plus/hooks/usePlusVoting.ts
@@ -1,39 +1,46 @@
import { useUser } from "hooks/common";
-import { getVotingRange } from "utils/plus";
+import { useState } from "react";
import { trpc } from "utils/trpc";
+import { Unpacked } from "utils/types";
+import { votesSchema } from "utils/validators/votes";
+import * as z from "zod";
export default function usePlusVoting() {
+ const [currentIndex, setCurrentIndex] = useState(0);
+ const [votes, setVotes] = useState>([]);
+
const [user] = useUser();
const { data: ballotsData, isLoading: isLoadingBallots } = trpc.useQuery([
- "plus.ballots",
+ "plus.usersForVoting",
]);
const { data: statusesData, isLoading: isLoadingStatuses } = trpc.useQuery([
"plus.statuses",
]);
- const {
- data: suggestionsData,
- isLoading: isLoadingSuggestions,
- } = trpc.useQuery(["plus.suggestions"]);
const ownPlusStatus = statusesData?.find(
(status) => status.user.id === user?.id
);
- const votingTier = ownPlusStatus?.membershipTier;
-
return {
- ballotsData: ballotsData?.filter((ballot) => !ballot.isStale),
- staleBallots: ballotsData?.filter((ballot) => ballot.isStale),
- shouldRedirect:
- (statusesData && !votingTier) || !getVotingRange().isHappening,
- usersToVoteOn: statusesData?.filter(
- (user) =>
- (user.membershipTier && user.membershipTier === votingTier) ||
- (user.vouchTier && user.vouchTier === votingTier)
- ),
- suggestedUsersToVoteOn: suggestionsData?.filter(
- (suggestion) => suggestion.tier === votingTier
- ),
- isLoading: isLoadingBallots || isLoadingStatuses || isLoadingSuggestions,
+ isLoading: isLoadingBallots || isLoadingStatuses,
+ shouldRedirect: !isLoadingBallots && !ballotsData,
+ plusStatus: ownPlusStatus,
+ currentUser: ballotsData?.[currentIndex],
+ previousUser:
+ currentIndex > 0 && ballotsData
+ ? { ...ballotsData[currentIndex - 1], ...votes[votes.length - 1] }
+ : undefined,
+ progress: ballotsData
+ ? ((currentIndex + 1) / ballotsData.length) * 100
+ : undefined,
+ handleVote: (vote: Unpacked>) => {
+ setVotes([...votes, vote]);
+ setCurrentIndex(currentIndex + 1);
+ (document.activeElement).blur();
+ },
+ goBack: () => {
+ setVotes(votes.slice(0, votes.length - 1));
+ setCurrentIndex(currentIndex - 1);
+ },
};
}
diff --git a/app/plus/service.ts b/app/plus/service.ts
index a36768376..03236a221 100644
--- a/app/plus/service.ts
+++ b/app/plus/service.ts
@@ -1,9 +1,11 @@
-import { Prisma } from "@prisma/client";
+import { PlusRegion, Prisma } from "@prisma/client";
import { httpError } from "@trpc/server";
import prisma from "prisma/client";
import { getPercentageFromCounts, getVotingRange } from "utils/plus";
import { userBasicSelection } from "utils/prisma";
+import { shuffleArray } from "utils/shuffleArray";
import { suggestionFullSchema } from "utils/validators/suggestion";
+import { votesSchema } from "utils/validators/votes";
import { vouchSchema } from "utils/validators/vouch";
import * as z from "zod";
@@ -163,9 +165,100 @@ const getDistinctSummaryMonths = () => {
});
};
-const getBallots = (userId: number) => {
+const getUsersForVoting = async (userId: number) => {
if (!getVotingRange().isHappening) return null;
- return prisma.plusBallot.findMany({ where: { voterUser: { id: userId } } });
+ const plusStatus = await prisma.plusStatus.findUnique({ where: { userId } });
+
+ if (!plusStatus?.membershipTier) return null;
+
+ const [plusStatuses, suggestions] = await Promise.all([
+ prisma.plusStatus.findMany({
+ where: {
+ OR: [
+ { membershipTier: plusStatus.membershipTier },
+ { membershipTier: plusStatus.membershipTier },
+ ],
+ },
+ include: { user: { include: { profile: true } } },
+ }),
+ prisma.plusSuggestion.findMany({
+ where: { tier: plusStatus.membershipTier },
+ include: {
+ suggestedUser: { include: { profile: true, plusStatus: true } },
+ suggesterUser: true,
+ },
+ orderBy: { createdAt: "asc" },
+ }),
+ ]);
+
+ const result: {
+ userId: number;
+ username: string;
+ discriminator: string;
+ discordAvatar: string | null;
+ discordId: string;
+ region: PlusRegion;
+ bio?: string | null;
+ suggestions?: {
+ description: string;
+ suggesterUser: {
+ id: number;
+ username: string;
+ discriminator: string;
+ };
+ }[];
+ }[] = [];
+
+ for (const status of plusStatuses) {
+ result.push({
+ userId: status.user.id,
+ username: status.user.username,
+ discriminator: status.user.discriminator,
+ discordAvatar: status.user.discordAvatar,
+ bio: status.user.profile?.bio,
+ discordId: status.user.discordId,
+ region: status.region,
+ });
+ }
+
+ for (const suggestion of suggestions) {
+ const user = result.find(
+ ({ userId }) => userId === suggestion.suggestedUser.id
+ );
+ if (user) {
+ user.suggestions?.push({
+ description: suggestion.description,
+ suggesterUser: {
+ id: suggestion.suggesterUser.id,
+ username: suggestion.suggesterUser.username,
+ discriminator: suggestion.suggestedUser.discriminator,
+ },
+ });
+ continue;
+ }
+
+ result.push({
+ userId: suggestion.suggestedUser.id,
+ username: suggestion.suggestedUser.username,
+ discriminator: suggestion.suggestedUser.discriminator,
+ discordAvatar: suggestion.suggestedUser.discordAvatar,
+ bio: suggestion.suggestedUser.profile?.bio,
+ discordId: suggestion.suggestedUser.discordId,
+ region: suggestion.suggestedUser.plusStatus?.region!,
+ suggestions: [
+ {
+ description: suggestion.description,
+ suggesterUser: {
+ id: suggestion.suggesterUser.id,
+ username: suggestion.suggesterUser.username,
+ discriminator: suggestion.suggestedUser.discriminator,
+ },
+ },
+ ],
+ });
+ }
+
+ return shuffleArray(result).sort((a, b) => a.region.localeCompare(b.region));
};
const addSuggestion = async ({
@@ -295,13 +388,95 @@ const addVouch = async ({
}
};
+const addVotes = async ({
+ input,
+ userId,
+}: {
+ input: z.infer;
+ userId: number;
+}) => {
+ const [plusStatuses, suggestions] = await Promise.all([
+ prisma.plusStatus.findMany({}),
+ prisma.plusSuggestion.findMany({ where: { isResuggestion: false } }),
+ ]);
+
+ const usersPlusStatus = plusStatuses.find(
+ (status) => status.userId === userId
+ );
+
+ const usersMembership = usersPlusStatus?.membershipTier;
+
+ if (!usersPlusStatus || !usersMembership)
+ throw httpError.badRequest("not a member");
+
+ const allowedUsers = new Map();
+
+ for (const status of plusStatuses) {
+ if (
+ status.membershipTier !== usersMembership &&
+ status.vouchTier !== usersMembership
+ ) {
+ continue;
+ }
+
+ allowedUsers.set(status.userId, status.region);
+ }
+
+ for (const suggestion of suggestions) {
+ if (suggestion.tier !== usersMembership) {
+ continue;
+ }
+
+ const status = plusStatuses.find(
+ (status) => status.userId === suggestion.suggestedId
+ );
+ if (!status)
+ throw httpError.badRequest("unexpected no status for suggested user");
+
+ allowedUsers.set(suggestion.suggestedId, status.region);
+ }
+
+ if (input.length !== allowedUsers.size) {
+ throw httpError.badRequest("didn't vote on every user exactly once");
+ }
+
+ if (
+ input.some((vote) => {
+ const region = allowedUsers.get(vote.userId);
+ if (!region) return false;
+
+ if (region === usersPlusStatus.region) {
+ if (![-2, -1, 1, 2].includes(vote.score)) return false;
+ } else {
+ if (![-1, 1].includes(vote.score)) return false;
+ }
+
+ return true;
+ })
+ ) {
+ throw httpError.badRequest("invalid vote provided");
+ }
+
+ return prisma.plusBallot.createMany({
+ data: input.map((vote) => {
+ return {
+ score: vote.score,
+ tier: usersMembership,
+ voterId: userId,
+ votedId: vote.userId,
+ };
+ }),
+ });
+};
+
export default {
getPlusStatuses,
getSuggestions,
getVotingSummariesByMonthAndTier,
getMostRecentVotingWithResultsMonth,
getDistinctSummaryMonths,
- getBallots,
+ getUsersForVoting,
addSuggestion,
addVouch,
+ addVotes,
};
diff --git a/utils/plus.ts b/utils/plus.ts
index d2c606d03..7d5ffedeb 100644
--- a/utils/plus.ts
+++ b/utils/plus.ts
@@ -59,5 +59,6 @@ export const getVotingRange = () => {
? startDate
: getThirdFridayDate(true);
- return { startDate, endDate, isHappening, nextVotingDate };
+ //return { startDate, endDate, isHappening, nextVotingDate };
+ return { startDate, endDate, isHappening: true, nextVotingDate };
};
diff --git a/utils/shuffleArray.ts b/utils/shuffleArray.ts
index 93dda2a1d..fd984115f 100644
--- a/utils/shuffleArray.ts
+++ b/utils/shuffleArray.ts
@@ -1,4 +1,4 @@
-export const shuffleArray = (array: string[]) => {
+export const shuffleArray = (array: T[]) => {
return array
.map((a) => ({ sort: Math.random(), value: a }))
.sort((a, b) => a.sort - b.sort)
diff --git a/utils/validators/votes.ts b/utils/validators/votes.ts
new file mode 100644
index 000000000..547b3f34b
--- /dev/null
+++ b/utils/validators/votes.ts
@@ -0,0 +1,8 @@
+import * as z from "zod";
+
+export const votesSchema = z.array(
+ z.object({
+ score: z.number().min(-2).max(2).int(),
+ userId: z.number(),
+ })
+);