can vouch

This commit is contained in:
Kalle
2021-02-27 15:06:30 +02:00
parent 7ec3b413ab
commit c93cc20f3a
11 changed files with 370 additions and 56 deletions

View File

@@ -1,38 +1,93 @@
import { Box, Center, Divider, Flex, Stack } from "@chakra-ui/layout";
import { Radio, RadioGroup } from "@chakra-ui/react";
import {
Alert,
AlertDescription,
AlertTitle,
chakra,
Radio,
RadioGroup,
} from "@chakra-ui/react";
import { Trans } from "@lingui/macro";
import SubText from "components/common/SubText";
import { useUser } from "hooks/common";
import { usePlus } from "hooks/plus";
import { getFullUsername } from "lib/strings";
import { Fragment } from "react";
import { Suggestions } from "services/plus";
import { PlusStatuses, Suggestions } from "services/plus";
import Suggestion from "./Suggestion";
import SuggestionVouchModal from "./SuggestionVouchModal";
import SuggestionModal from "./SuggestionModal";
import VouchModal from "./VouchModal";
export interface PlusHomePageProps {
suggestions: Suggestions;
statuses: PlusStatuses;
}
const PlusHomePage = ({ suggestions }: PlusHomePageProps) => {
const PlusHomePage = ({ suggestions, statuses }: PlusHomePageProps) => {
const [user] = useUser();
const {
plusStatusData,
suggestionsData,
ownSuggestion,
suggestionsLoading,
suggestionCounts,
setSuggestionsFilter,
} = usePlus(suggestions);
vouchedPlusStatusData,
} = usePlus({ suggestions, statuses });
return (
<>
{plusStatusData && plusStatusData.membershipTier && (
<SuggestionVouchModal
canSuggest={!suggestionsLoading && !ownSuggestion}
canVouch={!!plusStatusData.canVouchFor}
{plusStatusData && plusStatusData.membershipTier && !ownSuggestion && (
<SuggestionModal
userPlusMembershipTier={plusStatusData.membershipTier}
/>
)}
{plusStatusData &&
plusStatusData.canVouchFor &&
!plusStatusData.canVouchAgainAfter && (
<VouchModal canVouchFor={plusStatusData.canVouchFor} />
)}
{plusStatusData &&
(plusStatusData.canVouchAgainAfter ||
plusStatusData.voucher ||
vouchedPlusStatusData) && (
<Alert
status="success"
variant="subtle"
flexDirection="column"
alignItems="center"
justifyContent="center"
textAlign="center"
mt={2}
mb={6}
rounded="lg"
>
<AlertDescription maxWidth="sm">
<AlertTitle mb={1} fontSize="lg">
Vouching status
</AlertTitle>
{plusStatusData?.canVouchAgainAfter && (
<chakra.div>
Can vouch again after:{" "}
{new Date(
plusStatusData.canVouchAgainAfter
).toLocaleDateString()}
</chakra.div>
)}
{plusStatusData?.voucher && (
<chakra.div>
Vouched for <b>+{plusStatusData.vouchTier}</b> by{" "}
{getFullUsername(plusStatusData.voucher)}
</chakra.div>
)}
{vouchedPlusStatusData && (
<chakra.div>
Vouched {getFullUsername(vouchedPlusStatusData.user)} to{" "}
<b>+{vouchedPlusStatusData.vouchTier}</b>
</chakra.div>
)}
</AlertDescription>
</Alert>
)}
<Center mt={2}>
<RadioGroup
defaultValue="ALL"
@@ -69,9 +124,8 @@ const PlusHomePage = ({ suggestions }: PlusHomePageProps) => {
</Stack>
</RadioGroup>
</Center>
{!suggestionsLoading &&
suggestionCounts.ONE + suggestionCounts.TWO + suggestionCounts.THREE ===
0 ? (
{suggestionCounts.ONE + suggestionCounts.TWO + suggestionCounts.THREE ===
0 ? (
<Box mt={4}>No suggestions yet for this month</Box>
) : (
<>

View File

@@ -26,18 +26,12 @@ import UserSelector from "components/common/UserSelector";
import useMutation from "hooks/useMutation";
interface Props {
canVouch: boolean;
canSuggest: boolean;
userPlusMembershipTier?: number;
userPlusMembershipTier: number;
}
type FormData = z.infer<typeof suggestionFullSchema>;
const SuggestionVouchModal: React.FC<Props> = ({
canVouch,
canSuggest,
userPlusMembershipTier,
}) => {
const SuggestionModal: React.FC<Props> = ({ userPlusMembershipTier }) => {
const [isOpen, setIsOpen] = useState(false);
const { handleSubmit, errors, register, watch, control } = useForm<FormData>({
resolver: zodResolver(suggestionFullSchema),
@@ -51,17 +45,6 @@ const SuggestionVouchModal: React.FC<Props> = ({
const watchDescription = watch("description", "");
if (!canVouch && !canSuggest) return null;
const getButtonText = () => {
if (canSuggest && canVouch) return "Add new suggestion or vouch";
if (canVouch) return "Vouch";
return "Add new suggestion";
};
if (!userPlusMembershipTier) return null;
return (
<>
<Button
@@ -70,7 +53,7 @@ const SuggestionVouchModal: React.FC<Props> = ({
onClick={() => setIsOpen(true)}
data-cy="suggestion-button"
>
{getButtonText()}
Add new suggestion
</Button>
{isOpen && (
<Modal
@@ -81,7 +64,7 @@ const SuggestionVouchModal: React.FC<Props> = ({
>
<ModalOverlay>
<ModalContent>
<ModalHeader>Adding a new suggestion or vouch</ModalHeader>
<ModalHeader>Adding a new suggestion</ModalHeader>
<ModalCloseButton borderRadius="50%" />
<form onSubmit={handleSubmit(onSubmit)}>
<ModalBody pb={2}>
@@ -181,4 +164,4 @@ const SuggestionVouchModal: React.FC<Props> = ({
);
};
export default SuggestionVouchModal;
export default SuggestionModal;

View File

@@ -0,0 +1,140 @@
import {
Button,
Modal,
ModalCloseButton,
ModalHeader,
ModalOverlay,
ModalContent,
ModalBody,
ModalFooter,
FormControl,
FormLabel,
FormHelperText,
FormErrorMessage,
Select,
} from "@chakra-ui/react";
import { useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { vouchSchema } from "lib/validators/vouch";
import * as z from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import UserSelector from "components/common/UserSelector";
import useMutation from "hooks/useMutation";
interface Props {
canVouchFor: number;
}
type FormData = z.infer<typeof vouchSchema>;
const VouchModal: React.FC<Props> = ({ canVouchFor }) => {
const [isOpen, setIsOpen] = useState(false);
const { handleSubmit, errors, register, control } = useForm<FormData>({
resolver: zodResolver(vouchSchema),
});
const { onSubmit, sending } = useMutation({
onSuccess: () => setIsOpen(false),
route: "plus/vouch",
mutationKey: "plus",
successText: "Successfully vouched",
});
return (
<>
<Button
size="sm"
mb={4}
ml={2}
onClick={() => setIsOpen(true)}
data-cy="vouch-button"
>
Vouch
</Button>
{isOpen && (
<Modal
isOpen={isOpen}
onClose={() => setIsOpen(false)}
size="xl"
closeOnOverlayClick={false}
>
<ModalOverlay>
<ModalContent>
<ModalHeader>Vouching</ModalHeader>
<ModalCloseButton borderRadius="50%" />
<form onSubmit={handleSubmit(onSubmit)}>
<ModalBody pb={2}>
<FormLabel>Tier</FormLabel>
<Controller
name="tier"
control={control}
defaultValue={canVouchFor}
render={({ value, onChange }) => (
<Select
value={value}
onChange={(e) => onChange(Number(e.target.value))}
>
{canVouchFor === 1 && <option value="1">+1</option>}
{canVouchFor <= 2 && <option value="2">+2</option>}
{false && <option value="3">+3</option>}
</Select>
)}
/>
<FormControl isInvalid={!!errors.vouchedId}>
<FormLabel mt={4}>User</FormLabel>
<Controller
name="vouchedId"
control={control}
render={({ value, onChange }) => (
<UserSelector
value={value}
setValue={onChange}
isMulti={false}
maxMultiCount={undefined}
/>
)}
/>
<FormErrorMessage>
{errors.vouchedId?.message}
</FormErrorMessage>
</FormControl>
<FormControl>
<FormLabel mt={4}>Region</FormLabel>
<Select
name="region"
ref={register}
data-cy="region-select"
>
<option value="NA">NA</option>
<option value="EU">EU</option>
</Select>
<FormHelperText>
If the player isn't from either region then choose the one
they play most commonly with.
</FormHelperText>
</FormControl>
</ModalBody>
<ModalFooter>
<Button
mr={3}
type="submit"
isLoading={sending}
data-cy="submit-button"
>
Save
</Button>
<Button onClick={() => setIsOpen(false)} variant="outline">
Cancel
</Button>
</ModalFooter>
</form>
</ModalContent>
</ModalOverlay>
</Modal>
)}
</>
);
};
export default VouchModal;

View File

@@ -59,4 +59,28 @@ context("Plus Home Page", () => {
cy.contains('"yes agreed" - Sendou#4059');
cy.dataCy("comment-button").should("not.exist");
});
it("can add vouch", () => {
cy.login("sendou");
cy.visit("/plus");
cy.dataCy("vouch-button")
.click()
.get(".select__value-container")
.type("NZAP{enter}")
.dataCy("region-select")
.select("EU")
.dataCy("submit-button")
.click();
cy.dataCy("vouch-button").should("not.exist");
cy.contains("Vouched NZAP#6227");
});
it.only("can't vouch if canVouchAgainAfter set", () => {
cy.login("nzap");
cy.visit("/plus");
cy.dataCy("vouch-button").should("not.exist");
cy.contains("Can vouch again after:");
});
});

View File

@@ -1,31 +1,42 @@
import { useState } from "react";
import { PlusStatus, Suggestions } from "services/plus";
import { PlusStatuses, Suggestions } from "services/plus";
import useSWR from "swr";
import { useUser } from "./common";
export function usePlus(initialData: Suggestions) {
export function usePlus({
suggestions: suggestionsInitial,
statuses: statusesInitial,
}: {
suggestions: Suggestions;
statuses: PlusStatuses;
}) {
const [user] = useUser();
const [suggestionsFilter, setSuggestionsFilter] = useState<
number | undefined
>(undefined);
const { data: plusStatusData } = useSWR<PlusStatus>(
user ? "/api/plus" : null
const { data: plusStatusData } = useSWR<PlusStatuses>(
user ? "/api/plus" : null,
{ initialData: statusesInitial }
);
const { data: suggestionsData } = useSWR<Suggestions>(
"/api/plus/suggestions",
{ initialData }
{ initialData: suggestionsInitial }
);
const suggestions = suggestionsData ?? [];
return {
plusStatusData: plusStatusData?.status,
plusStatusData: plusStatusData?.find(
(status) => status.user.id === user?.id
),
vouchedPlusStatusData: plusStatusData?.find(
(status) => status.voucher?.id === user?.id
),
suggestionsData: suggestions.filter(
(suggestion) =>
!suggestionsFilter || suggestion.tier === suggestionsFilter
),
suggestionsLoading: !suggestionsData,
suggestionCounts: suggestions.reduce(
(counts, suggestion) => {
const tierString = [null, "ONE", "TWO", "THREE"][

7
lib/validators/vouch.ts Normal file
View File

@@ -0,0 +1,7 @@
import * as z from "zod";
export const vouchSchema = z.object({
vouchedId: z.number().int(),
tier: z.number().int().min(1).max(3),
region: z.enum(["NA", "EU"]),
});

View File

@@ -3,8 +3,6 @@ import { NextApiRequest, NextApiResponse } from "next";
import plusService from "services/plus";
const plusHandler = async (req: NextApiRequest, res: NextApiResponse) => {
const user = await getMySession(req);
switch (req.method) {
case "GET":
await getHandler(req, res);
@@ -14,10 +12,8 @@ const plusHandler = async (req: NextApiRequest, res: NextApiResponse) => {
}
async function getHandler(_req: NextApiRequest, res: NextApiResponse) {
if (!user) return res.status(401).end();
try {
res.status(200).json(await plusService.getPlusStatus(user.id));
res.status(200).json(await plusService.getPlusStatuses());
} catch (e) {
console.error(e.message);
res.status(500).end();

40
pages/api/plus/vouch.ts Normal file
View File

@@ -0,0 +1,40 @@
import { getMySession } from "lib/api";
import { UserError } from "lib/errors";
import { NextApiRequest, NextApiResponse } from "next";
import plusService from "services/plus";
import { ZodError } from "zod";
const vouchHandler = async (req: NextApiRequest, res: NextApiResponse) => {
const user = await getMySession(req);
switch (req.method) {
case "POST":
await postHandler(req, res);
break;
default:
res.status(405).end();
}
async function postHandler(req: NextApiRequest, res: NextApiResponse) {
if (!user) return res.status(401).end();
try {
await plusService.addVouch({ data: req.body, userId: user.id });
} catch (e) {
if (e instanceof ZodError) {
res.status(400).json({ message: e.message });
} else if (e instanceof UserError) {
res.status(400).json({ message: e.message });
} else {
console.error(e.message);
res.status(500).end();
}
return;
}
res.status(200).end();
}
};
export default vouchHandler;

View File

@@ -4,10 +4,16 @@ import { GetStaticProps } from "next";
import plusService from "services/plus";
export const getStaticProps: GetStaticProps<PlusHomePageProps> = async () => {
const suggestions = await plusService.getSuggestions();
const [suggestions, statuses] = await Promise.all([
plusService.getSuggestions(),
plusService.getPlusStatuses(),
]);
return {
props: { suggestions: JSON.parse(JSON.stringify(suggestions)) },
props: {
suggestions: JSON.parse(JSON.stringify(suggestions)),
statuses: JSON.parse(JSON.stringify(statuses)),
},
revalidate: 60,
};
};

View File

@@ -57,11 +57,14 @@ export const getPlusStatusesData = (): Prisma.PlusStatusCreateManyInput[] => {
userId: 11,
region: "EU",
membershipTier: 1,
canVouchFor: 1,
},
{
userId: 12,
region: "EU",
membershipTier: 2,
canVouchAgainAfter: new Date(Date.UTC(2030, 1, 1)),
canVouchFor: 2,
},
];
};

View File

@@ -3,13 +3,13 @@ import { UserError } from "lib/errors";
import { getPercentageFromCounts } from "lib/plus";
import { userBasicSelection } from "lib/prisma";
import { suggestionFullSchema } from "lib/validators/suggestion";
import { vouchSchema } from "lib/validators/vouch";
import prisma from "prisma/client";
export type PlusStatus = Prisma.PromiseReturnType<typeof getPlusStatus>;
export type PlusStatuses = Prisma.PromiseReturnType<typeof getPlusStatuses>;
const getPlusStatus = async (userId: number) => {
const status = await prisma.plusStatus.findUnique({
where: { userId },
const getPlusStatuses = async () => {
return prisma.plusStatus.findMany({
select: {
canVouchAgainAfter: true,
vouchTier: true,
@@ -17,10 +17,9 @@ const getPlusStatus = async (userId: number) => {
membershipTier: true,
region: true,
voucher: { select: userBasicSelection },
user: { select: userBasicSelection },
},
});
return { status: status ?? null };
};
export type Suggestions = Prisma.PromiseReturnType<typeof getSuggestions>;
@@ -237,11 +236,62 @@ const addSuggestion = async ({
}
};
const addVouch = async ({
data,
userId,
}: {
data: unknown;
userId: number;
}) => {
const parsedData = vouchSchema.parse(data);
const plusStatuses = await prisma.plusStatus.findMany({});
const suggesterPlusStatus = plusStatuses.find(
(status) => status.userId === userId
);
if ((suggesterPlusStatus?.canVouchFor ?? Infinity) > parsedData.tier) {
throw new UserError(
"not a member of high enough tier to vouch for this tier"
);
}
if (vouchedUserAlreadyHasAccess()) {
throw new UserError("vouched user already has access");
}
// TODO voting has started
return prisma.$transaction([
prisma.plusStatus.upsert({
where: { userId: parsedData.vouchedId },
create: { region: parsedData.region, userId: parsedData.vouchedId },
update: { voucherId: userId, vouchTier: parsedData.tier },
}),
prisma.plusStatus.update({
where: { userId },
data: { canVouchFor: null },
}),
]);
function vouchedUserAlreadyHasAccess() {
const suggestedPlusStatus = plusStatuses.find(
(status) => status.userId === parsedData.vouchedId
);
return Boolean(
suggestedPlusStatus &&
((suggestedPlusStatus.membershipTier ?? 999) <= parsedData.tier ||
(suggestedPlusStatus.vouchTier ?? 999) <= parsedData.tier)
);
}
};
export default {
getPlusStatus,
getPlusStatuses,
getSuggestions,
getVotingSummariesByMonthAndTier,
getMostRecentVotingWithResultsMonth,
getDistinctSummaryMonths,
addSuggestion,
addVouch,
};