Add db edit feature for admin

This commit is contained in:
pikiou 2022-05-24 15:30:15 +02:00
parent df33d3a951
commit da643df6a6
34 changed files with 593 additions and 276 deletions

View File

@ -0,0 +1,34 @@
import { FC, memo } from "react"
import { useSelector } from "react-redux"
import withUserConnected from "../../utils/withUserConnected"
import { fetchVolunteerListIfNeed, selectVolunteerList } from "../../store/volunteerList"
import withUserRole from "../../utils/withUserRole"
import ROLES from "../../utils/roles.constants"
import MemberEdit from "./MemberEdit"
import useAction from "../../utils/useAction"
import { fetchVolunteerSetIfNeed } from "../../store/volunteerSet"
import { Volunteer } from "../../services/volunteers"
import styles from "./styles.module.scss"
const DbEdit: FC = (): JSX.Element => {
const volunteers = useSelector(selectVolunteerList)
const saveVolunteer = useAction(fetchVolunteerSetIfNeed)
if (!volunteers) {
return <>No member found</>
}
return (
<ul className={styles.list}>
{volunteers.map((volunteer: Volunteer) => (
<MemberEdit
key={volunteer.id}
saveVolunteer={saveVolunteer}
volunteer={volunteer}
/>
))}
</ul>
)
}
export default withUserRole(ROLES.ADMIN, memo(withUserConnected(DbEdit)))
export const fetchFor = [fetchVolunteerListIfNeed]

View File

@ -0,0 +1,31 @@
import { FC, memo, useCallback } from "react"
import { useSelector } from "react-redux"
import withUserConnected from "../../utils/withUserConnected"
import withUserRole from "../../utils/withUserRole"
import ROLES from "../../utils/roles.constants"
import { fetchGameDetailsUpdate } from "../../store/gameDetailsUpdate"
import { selectUserJwtToken } from "../../store/auth"
import useAction from "../../utils/useAction"
import styles from "./styles.module.scss"
import FormButton from "../Form/FormButton/FormButton"
const GameDetailsUpdate: FC = (): JSX.Element => {
const jwtToken = useSelector(selectUserJwtToken)
const save = useAction(fetchGameDetailsUpdate)
const onSubmit = useCallback(async () => {
await save(jwtToken)
}, [save, jwtToken])
return (
<>
<div className={styles.formButtons}>
<FormButton onClick={onSubmit}>Ok, noté</FormButton>
</div>
</>
)
}
export default withUserRole(ROLES.ADMIN, memo(withUserConnected(GameDetailsUpdate)))
export const fetchFor = [fetchGameDetailsUpdate]

View File

@ -0,0 +1,117 @@
import { isFinite } from "lodash"
import { FC, memo, useState } from "react"
import withUserConnected from "../../utils/withUserConnected"
import withUserRole from "../../utils/withUserRole"
import ROLES from "../../utils/roles.constants"
import { Volunteer } from "../../services/volunteers"
import styles from "./styles.module.scss"
import { toastError } from "../../store/utils"
interface Props {
volunteer: Volunteer
saveVolunteer: (newVolunteer: Partial<Volunteer>) => void
}
const MemberEdit: FC<Props> = ({ volunteer, saveVolunteer }): JSX.Element => {
const [localVolunteer, setLocalVolunteer] = useState(volunteer)
const stringDispatch =
(propName: string) =>
(e: React.ChangeEvent<HTMLInputElement>): void => {
saveVolunteer({ id: localVolunteer.id, [propName]: e.target.value })
setLocalVolunteer({ ...localVolunteer, [propName]: e.target.value })
}
function stringInput(id: string, value: string): JSX.Element {
return (
<div key={id} className={styles.inputContainer}>
<span className={styles.inputDesc}>{id}</span>
<br />
<input
type="text"
id={id}
value={value}
onChange={stringDispatch(id)}
className={styles.stringInput}
/>
</div>
)
}
const numberDispatch =
(propName: string) =>
(e: React.ChangeEvent<HTMLInputElement>): void => {
const value: number = +e.target.value
if (!isFinite(value)) {
toastError("Should be a number")
return
}
saveVolunteer({ id: localVolunteer.id, [propName]: +value })
setLocalVolunteer({ ...localVolunteer, [propName]: +value })
}
function numberInput(id: string, value: number): JSX.Element {
return (
<div key={id} className={styles.inputContainer}>
<span className={styles.inputDesc}>{id}</span>
<br />
<input
type="text"
id={id}
value={value}
onChange={numberDispatch(id)}
className={styles.numberInput}
/>
</div>
)
}
const booleanDispatch =
(propName: string) =>
(e: React.ChangeEvent<HTMLInputElement>): void => {
const value: boolean = e.target.value !== "0" && e.target.value !== ""
saveVolunteer({ id: localVolunteer.id, [propName]: value })
setLocalVolunteer({ ...localVolunteer, [propName]: value })
}
function booleanInput(id: string, value: boolean): JSX.Element {
return (
<div key={id} className={styles.inputContainer}>
<span className={styles.inputDesc}>{id}</span>
<br />
<input
type="text"
id={id}
value={value ? "X" : ""}
onChange={booleanDispatch(id)}
className={styles.booleanInput}
/>
</div>
)
}
const volunteerDefault = new Volunteer()
const typeHandler: { [id: string]: (id: string, value: any) => JSX.Element } = {
string: stringInput,
number: numberInput,
boolean: booleanInput,
}
const keys = Object.keys(volunteerDefault) as (keyof Volunteer)[]
return (
<li className={styles.item} key={volunteer.id}>
{keys.map((key) => {
const valueType = typeof volunteerDefault[key]
const value = localVolunteer[key]
return (
typeHandler[valueType as string]?.(key, value as any) ||
stringInput(key, value as any)
)
})}
</li>
)
}
export default withUserRole(ROLES.ADMIN, memo(withUserConnected(MemberEdit)))
export const fetchFor = []

View File

@ -0,0 +1,39 @@
@import "../../theme/variables";
@import "../../theme/mixins";
.title {
padding-bottom: 10px;
font-weight: bold;
}
.list {
padding: 0;
list-style: none;
}
.item {
padding: 10px 0;
}
.formButtons {
margin-top: 10px;
padding: 5px 0;
text-align: center;
}
.inputContainer {
display: inline-block;
}
.stringInput {
width: 5em;
}
.numberInput {
width: 3em;
}
.booleanInput {
width: 1.5em;
}
.inputDesc {
font-size: small;
}

View File

@ -29,8 +29,6 @@ describe("<List />", () => {
"5": { "5": {
id: 5, id: 5,
title: "6 qui prend!", title: "6 qui prend!",
author: "Wolfgang Kramer",
editor: "(uncredited) , Design Edge , B",
playersMin: 2, playersMin: 2,
playersMax: 10, playersMax: 10,
duration: 45, duration: 45,

View File

@ -10,7 +10,7 @@ const KnowledgeIntro: React.FC = (): JSX.Element => (
</p> </p>
<p> <p>
OK signifie que tu peux expliquer le jeu, avec au maximum un coup d'oeil aux règles sur OK signifie que tu peux expliquer le jeu, avec au maximum un coup d'oeil aux règles sur
un nombre de cartes à distribuer, ou un nombre de PV déclancheur de fin de partie. un nombre de cartes à distribuer, ou un nombre de PV déclencheur de fin de partie.
<br /> <br />
Bof signifie que tu seras plus utile que la lecture des règles. Bof signifie que tu seras plus utile que la lecture des règles.
</p> </p>

View File

@ -1,4 +1,4 @@
import React, { memo, useEffect, useState } from "react" import { memo, useEffect, useState } from "react"
import { useSelector, shallowEqual } from "react-redux" import { useSelector, shallowEqual } from "react-redux"
import { toast } from "react-toastify" import { toast } from "react-toastify"
import _ from "lodash" import _ from "lodash"
@ -11,6 +11,12 @@ import { fetchVolunteerPartialAdd } from "../../store/volunteerPartialAdd"
import FormButton from "../Form/FormButton/FormButton" import FormButton from "../Form/FormButton/FormButton"
import { validEmail } from "../../utils/standardization" import { validEmail } from "../../utils/standardization"
import { toastError } from "../../store/utils" import { toastError } from "../../store/utils"
import {
sendBooleanRadioboxDispatch,
sendTextareaDispatch,
sendRadioboxDispatch,
sendTextDispatch,
} from "../input.utils"
import { import {
fetchMiscMeetingDateListIfNeed, fetchMiscMeetingDateListIfNeed,
selectMiscMeetingDateList, selectMiscMeetingDateList,
@ -57,26 +63,6 @@ const RegisterForm = ({ dispatch }: Props): JSX.Element => {
}, [changingBackground, setChangingBackground]) }, [changingBackground, setChangingBackground])
const transitionClass = (i: number) => animations[changingBackground][i - 1] const transitionClass = (i: number) => animations[changingBackground][i - 1]
const sendTextDispatch =
(dispatchSetter: React.Dispatch<React.SetStateAction<string>>) =>
(e: React.ChangeEvent<HTMLInputElement>) =>
dispatchSetter(e.target.value)
const sendTextareaDispatch =
(dispatchSetter: React.Dispatch<React.SetStateAction<string>>) =>
(e: React.ChangeEvent<HTMLTextAreaElement>) =>
dispatchSetter(e.target.value)
const sendBooleanRadioboxDispatch =
(dispatchSetter: React.Dispatch<React.SetStateAction<boolean>>, isYes: boolean) =>
(e: React.ChangeEvent<HTMLInputElement>) =>
dispatchSetter(isYes ? !!e.target.value : !e.target.value)
const sendRadioboxDispatch =
(dispatchSetter: React.Dispatch<React.SetStateAction<string>>) =>
(e: React.ChangeEvent<HTMLInputElement>) =>
dispatchSetter(e.target.value)
const onSubmit = () => { const onSubmit = () => {
if (!validEmail(email)) { if (!validEmail(email)) {
toastError("Cet email est invalid ><") toastError("Cet email est invalid ><")

View File

@ -1,21 +0,0 @@
/**
* @jest-environment jsdom
*/
import { render } from "@testing-library/react"
import { MemoryRouter } from "react-router-dom"
import { volunteerExample } from "../../../services/volunteers"
import VolunteerSet from "../index"
describe("<SetVolunteer />", () => {
it("renders", () => {
const dispatch = jest.fn()
const tree = render(
<MemoryRouter>
<VolunteerSet dispatch={dispatch} volunteer={volunteerExample} />
</MemoryRouter>
).container.firstChild
expect(tree).toMatchSnapshot()
})
})

View File

@ -1,51 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<SetVolunteer /> renders 1`] = `
<section
class="VolunteerList"
>
<h2>
Modifier un volunteer
</h2>
<form>
<label
for="postFirstname"
>
Prénom:
<input
id="postFirstname"
name="postFirstname"
type="text"
value="Aupeix"
/>
</label>
<label
for="postName"
>
Nom:
<input
id="postName"
name="postName"
type="text"
value="Amélie"
/>
</label>
<label
for="postAdult"
>
Majeur:
<input
id="postAdult"
name="postAdult"
type="text"
value="1"
/>
</label>
<button
type="button"
>
Save changes
</button>
</form>
</section>
`;

View File

@ -1,88 +0,0 @@
import React, { useState, memo } from "react"
import { toast } from "react-toastify"
import { AppDispatch } from "../../store"
import { fetchVolunteerSet } from "../../store/volunteerSet"
import { Volunteer } from "../../services/volunteers"
import styles from "./styles.module.scss"
interface Props {
dispatch: AppDispatch
volunteer: Volunteer
}
const VolunteerSet = ({ dispatch, volunteer }: Props) => {
const [firstname, setFirstname] = useState(volunteer.firstname)
const [lastname, setName] = useState(volunteer.lastname)
const [adult, setAdult] = useState(volunteer.adult)
const onFirstnameChanged = (e: React.ChangeEvent<HTMLInputElement>) =>
setFirstname(e.target.value)
const onNameChanged = (e: React.ChangeEvent<HTMLInputElement>) => setName(e.target.value)
const onAdultChanged = (e: React.ChangeEvent<HTMLInputElement>) => setAdult(+e.target.value)
const onSavePostClicked = () => {
if (firstname && lastname) {
dispatch(
fetchVolunteerSet({
...volunteer,
firstname,
lastname,
adult,
})
)
} else {
toast.warning("Il faut au moins préciser un prenom et un nom", {
position: "top-center",
autoClose: 6000,
hideProgressBar: true,
closeOnClick: true,
pauseOnHover: true,
draggable: true,
progress: undefined,
})
}
}
return (
<section className={styles.VolunteerList}>
<h2>Modifier un volunteer</h2>
<form>
<label htmlFor="postFirstname">
Prénom:
<input
type="text"
id="postFirstname"
name="postFirstname"
value={firstname}
onChange={onFirstnameChanged}
/>
</label>
<label htmlFor="postName">
Nom:
<input
type="text"
id="postName"
name="postName"
value={lastname}
onChange={onNameChanged}
/>
</label>
<label htmlFor="postAdult">
Majeur:
<input
type="text"
id="postAdult"
name="postAdult"
value={adult}
onChange={onAdultChanged}
/>
</label>
<button type="button" onClick={onSavePostClicked}>
Save changes
</button>
</form>
</section>
)
}
export default memo(VolunteerSet)

View File

@ -1,17 +0,0 @@
@import "../../theme/variables";
.VolunteerList {
color: $color-white;
ul {
padding-left: 17px;
}
li {
margin-bottom: 0.5em;
}
a {
color: $color-white;
}
}

View File

@ -1,4 +1,6 @@
import AnnouncementLink from "./AnnouncementLink" import AnnouncementLink from "./AnnouncementLink"
import DbEdit, { fetchFor as fetchForDbEdit } from "./Admin/DbEdit"
import GameDetailsUpdate, { fetchFor as fetchForGameDetailsUpdate } from "./Admin/GameDetailsUpdate"
import Board, { fetchFor as fetchForBoard } from "./VolunteerBoard/Board" import Board, { fetchFor as fetchForBoard } from "./VolunteerBoard/Board"
import DayWishesForm, { import DayWishesForm, {
fetchFor as fetchForDayWishesForm, fetchFor as fetchForDayWishesForm,
@ -20,11 +22,14 @@ import TeamWishesForm, {
} from "./VolunteerBoard/TeamWishesForm/TeamWishesForm" } from "./VolunteerBoard/TeamWishesForm/TeamWishesForm"
import VolunteerList from "./VolunteerList" import VolunteerList from "./VolunteerList"
import VolunteerInfo from "./VolunteerInfo" import VolunteerInfo from "./VolunteerInfo"
import VolunteerSet from "./VolunteerSet"
import WishAdd from "./WishAdd" import WishAdd from "./WishAdd"
export { export {
AnnouncementLink, AnnouncementLink,
DbEdit,
fetchForDbEdit,
GameDetailsUpdate,
fetchForGameDetailsUpdate,
Board, Board,
fetchForBoard, fetchForBoard,
BoxList, BoxList,
@ -48,6 +53,5 @@ export {
fetchForTeamWishesForm, fetchForTeamWishesForm,
VolunteerInfo, VolunteerInfo,
VolunteerList, VolunteerList,
VolunteerSet,
WishAdd, WishAdd,
} }

22
src/components/input.utils.ts Executable file
View File

@ -0,0 +1,22 @@
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
import React from "react"
export const sendTextDispatch =
(dispatchSetter: React.Dispatch<React.SetStateAction<string>>) =>
(e: React.ChangeEvent<HTMLInputElement>) =>
dispatchSetter(e.target.value)
export const sendTextareaDispatch =
(dispatchSetter: React.Dispatch<React.SetStateAction<string>>) =>
(e: React.ChangeEvent<HTMLTextAreaElement>) =>
dispatchSetter(e.target.value)
export const sendBooleanRadioboxDispatch =
(dispatchSetter: React.Dispatch<React.SetStateAction<boolean>>, isYes: boolean) =>
(e: React.ChangeEvent<HTMLInputElement>) =>
dispatchSetter(isYes ? !!e.target.value : !e.target.value)
export const sendRadioboxDispatch =
(dispatchSetter: React.Dispatch<React.SetStateAction<string>>) =>
(e: React.ChangeEvent<HTMLInputElement>) =>
dispatchSetter(e.target.value)

View File

@ -0,0 +1,28 @@
import { FC, memo } from "react"
import { RouteComponentProps } from "react-router-dom"
import { useSelector } from "react-redux"
import { AppThunk } from "../../../store"
import { selectUserJwtToken } from "../../../store/auth"
import { DbEdit, fetchForDbEdit } from "../../../components"
export type Props = RouteComponentProps
const DbEditPage: FC<Props> = (): JSX.Element => {
const jwtToken = useSelector(selectUserJwtToken)
if (jwtToken === undefined) return <p>Loading...</p>
if (jwtToken) {
return (
<>
<DbEdit />
</>
)
}
return <div>Besoin d'être identifié</div>
}
// Fetch server-side data here
export const loadData = (): AppThunk[] => [...fetchForDbEdit.map((f) => f())]
export default memo(DbEditPage)

View File

@ -0,0 +1,16 @@
import loadable from "@loadable/component"
import { Loading, ErrorBoundary } from "../../../components"
import { Props, loadData } from "./DbEdit"
const DbEditPage = loadable(() => import("./DbEdit"), {
fallback: <Loading />,
})
export default (props: Props): JSX.Element => (
<ErrorBoundary>
<DbEditPage {...props} />
</ErrorBoundary>
)
export { loadData }

View File

@ -0,0 +1,29 @@
import { FC, memo } from "react"
import { RouteComponentProps } from "react-router-dom"
import { useSelector } from "react-redux"
import { AppThunk } from "../../../store"
import { selectUserJwtToken } from "../../../store/auth"
import { GameDetailsUpdate } from "../../../components"
import { fetchGameDetailsUpdateIfNeed } from "../../../store/gameDetailsUpdate"
export type Props = RouteComponentProps
const GameDetailsUpdatePage: FC<Props> = (): JSX.Element => {
const jwtToken = useSelector(selectUserJwtToken)
if (jwtToken === undefined) return <p>Loading...</p>
if (jwtToken) {
return (
<>
<GameDetailsUpdate />
</>
)
}
return <div>Besoin d'être identifié</div>
}
// Fetch server-side data here
export const loadData = (): AppThunk[] => [...[fetchGameDetailsUpdateIfNeed].map((f) => f())]
export default memo(GameDetailsUpdatePage)

View File

@ -0,0 +1,16 @@
import loadable from "@loadable/component"
import { Loading, ErrorBoundary } from "../../../components"
import { Props, loadData } from "./GameDetailsUpdate"
const GameDetailsUpdatePage = loadable(() => import("./GameDetailsUpdate"), {
fallback: <Loading />,
})
export default (props: Props): JSX.Element => (
<ErrorBoundary>
<GameDetailsUpdatePage {...props} />
</ErrorBoundary>
)
export { loadData }

View File

@ -1,6 +1,10 @@
import { RouteConfig } from "react-router-config" import { RouteConfig } from "react-router-config"
import App from "../app" import App from "../app"
import AsyncDbEdit, { loadData as loadDbEdit } from "../pages/Admin/DbEdit"
import AsyncGameDetailsUpdate, {
loadData as loadGameDetailsUpdate,
} from "../pages/Admin/GameDetailsUpdate"
import AsyncHome, { loadData as loadHomeData } from "../pages/Home" import AsyncHome, { loadData as loadHomeData } from "../pages/Home"
import AsyncAnnouncements, { loadData as loadAnnouncementsData } from "../pages/Announcements" import AsyncAnnouncements, { loadData as loadAnnouncementsData } from "../pages/Announcements"
import AsyncTeamAssignment, { loadData as loadTeamAssignmentData } from "../pages/TeamAssignment" import AsyncTeamAssignment, { loadData as loadTeamAssignmentData } from "../pages/TeamAssignment"
@ -24,6 +28,16 @@ export default [
component: AsyncHome, component: AsyncHome,
loadData: loadHomeData, loadData: loadHomeData,
}, },
{
path: "/edit",
component: AsyncDbEdit,
loadData: loadDbEdit,
},
{
path: "/updateGameDetails",
component: AsyncGameDetailsUpdate,
loadData: loadGameDetailsUpdate,
},
{ {
path: "/connaissances", path: "/connaissances",
component: AsyncKnowledge, component: AsyncKnowledge,

View File

@ -1,6 +1,6 @@
// eslint-disable-next-line max-classes-per-file // eslint-disable-next-line max-classes-per-file
import path from "path" import path from "path"
import _ from "lodash" import _, { assign, pick } from "lodash"
import { promises as fs, constants } from "fs" import { promises as fs, constants } from "fs"
import { GoogleSpreadsheet, GoogleSpreadsheetWorksheet } from "google-spreadsheet" import { GoogleSpreadsheet, GoogleSpreadsheetWorksheet } from "google-spreadsheet"
import { SheetNames, saveLocalDb, loadLocalDb } from "./localDb" import { SheetNames, saveLocalDb, loadLocalDb } from "./localDb"
@ -187,6 +187,33 @@ export class Sheet<
this._type = db.type as Record<keyof Element, string> this._type = db.type as Record<keyof Element, string>
} }
parseRawPartialElement(
rawPartialElement: Partial<Record<keyof Element, string>>
): Partial<Element> | undefined {
if (this._type === undefined) {
return undefined
}
// else
const rawPartialFrenchElement = _.mapValues(
this.invertedTranslation,
(englishProp: string) => (rawPartialElement as any)[englishProp]
) as Element
const rawFrenchElement = this.stringifyElement(this.frenchSpecimen, this._type)
assign(rawFrenchElement, rawPartialFrenchElement)
const frenchElement = this.parseElement(rawFrenchElement, this._type)
const element = _.mapValues(
this.translation,
(frenchProp: string) => (frenchElement as any)[frenchProp]
) as Element
const partialElement = pick(element, Object.keys(rawPartialElement))
return partialElement
}
dbSave(): void { dbSave(): void {
this.saveTimestamp = +new Date() this.saveTimestamp = +new Date()

View File

@ -39,6 +39,12 @@ export default class ExpressAccessors<
return this.sheet as Sheet<ElementNoId, Element> return this.sheet as Sheet<ElementNoId, Element>
} }
parseRawPartialElement(
rawPartialElement: Partial<Record<keyof Element, string>>
): Partial<Element> | undefined {
return this.sheet?.parseRawPartialElement(rawPartialElement)
}
listGet() { listGet() {
return async ( return async (
_request: Request, _request: Request,
@ -56,6 +62,45 @@ export default class ExpressAccessors<
} }
} }
listSet(
custom?: (
list: Element[],
body: RequestBody,
id: number,
roles: string[]
) => Promise<CustomSetReturn<Element[]>> | CustomSetReturn<Element[]>
) {
return async (request: Request, response: Response, _next: NextFunction): Promise<void> => {
try {
const sheet = await this.getSheet()
if (!custom) {
await sheet.setList(request.body)
response.status(200)
} else {
const memberId = response?.locals?.jwt?.id || -1
const roles: string[] = response?.locals?.jwt?.roles || []
const list = (await sheet.getList()) || []
const { toDatabase, toCaller } = await custom(
list,
request.body,
memberId,
roles
)
if (toDatabase !== undefined) {
await sheet.setList(toDatabase)
}
if (toCaller !== undefined) {
response.status(200).json(toCaller)
} else {
response.status(200)
}
}
} catch (e: any) {
response.status(200).json({ error: e.message })
}
}
}
// custom can be async // custom can be async
get<Ret = Element>( get<Ret = Element>(
custom?: ( custom?: (

View File

@ -1,3 +1,4 @@
import { cloneDeep } from "lodash"
import ExpressAccessors from "./expressAccessors" import ExpressAccessors from "./expressAccessors"
import { Game, GameWithoutId, translationGame } from "../../services/games" import { Game, GameWithoutId, translationGame } from "../../services/games"
@ -11,3 +12,21 @@ export const gameListGet = expressAccessor.listGet()
// export const gameGet = expressAccessor.get() // export const gameGet = expressAccessor.get()
// export const gameAdd = expressAccessor.add() // export const gameAdd = expressAccessor.add()
// export const gameSet = expressAccessor.set() // export const gameSet = expressAccessor.set()
export const gameDetailsUpdate = expressAccessor.listSet(async (list, _body, _id, roles) => {
if (!roles.includes("admin")) {
throw Error(
`À moins d'être admin, on ne peut pas modifier n'importe quel jeu, ${JSON.stringify(
roles
)}`
)
}
const newList = cloneDeep(list)
// TODO update game list details from BGG
return {
toDatabase: newList,
toCaller: newList,
}
})

View File

@ -1,4 +1,4 @@
import { assign, cloneDeep, keys, omit, pick } from "lodash" import { assign, cloneDeep, omit, pick } from "lodash"
import bcrypt from "bcrypt" import bcrypt from "bcrypt"
import sgMail from "@sendgrid/mail" import sgMail from "@sendgrid/mail"
@ -30,7 +30,30 @@ export const volunteerListGet = expressAccessor.get(async (list, _body, id) => {
} }
return list return list
}) })
export const volunteerSet = expressAccessor.set()
export const volunteerSet = expressAccessor.set(async (list, body, _id, roles) => {
if (!roles.includes("admin")) {
throw Error(`À moins d'être admin, on ne peut pas modifier n'importe quel utilisateur`)
}
const newPartialVolunteer = body[0] as Partial<Record<keyof Volunteer, string>> & { id: number }
const volunteer: Volunteer | undefined = list.find((v) => v.id === newPartialVolunteer.id)
if (!volunteer) {
throw Error(`Il n'y a aucun bénévole avec cet identifiant ${newPartialVolunteer.id}`)
}
const newVolunteer: Volunteer = cloneDeep(volunteer)
const parsedPartialVolunteer = expressAccessor.parseRawPartialElement(newPartialVolunteer)
if (parsedPartialVolunteer === undefined) {
throw Error(`Erreur au parsing dans volunteerSet`)
}
assign(newVolunteer, parsedPartialVolunteer)
return {
toDatabase: newVolunteer,
toCaller: newVolunteer,
}
})
export const volunteerDiscordId = expressAccessor.get(async (list, body, id) => { export const volunteerDiscordId = expressAccessor.get(async (list, body, id) => {
const requestedId = +body[0] || id const requestedId = +body[0] || id
@ -132,7 +155,7 @@ export const volunteerLogin = expressAccessor.get<VolunteerLogin>(async (list, b
const lastForgot: { [id: string]: number } = {} const lastForgot: { [id: string]: number } = {}
export const volunteerForgot = expressAccessor.set(async (list, bodyArray) => { export const volunteerForgot = expressAccessor.set(async (list, bodyArray) => {
const [body] = bodyArray const [body] = bodyArray
const volunteer = getByEmail(list, body.email) const volunteer: Volunteer | undefined = getByEmail(list, body.email)
if (!volunteer) { if (!volunteer) {
throw Error("Il n'y a aucun bénévole avec cet email") throw Error("Il n'y a aucun bénévole avec cet email")
} }
@ -199,15 +222,17 @@ export const volunteerAsksSet = expressAccessor.set(async (list, body, id) => {
} }
const newVolunteer = cloneDeep(volunteer) const newVolunteer = cloneDeep(volunteer)
assign(newVolunteer, pick(notifChanges, keys(newVolunteer))) if (notifChanges.teamWishes !== undefined) newVolunteer.hiddenAsks = notifChanges.hiddenAsks
if (notifChanges.acceptsNotifs !== undefined)
newVolunteer.acceptsNotifs = notifChanges.acceptsNotifs
if (notifChanges.pushNotifSubscription !== undefined)
newVolunteer.pushNotifSubscription = notifChanges.pushNotifSubscription
return { return {
toDatabase: newVolunteer, toDatabase: newVolunteer,
toCaller: { toCaller: {
id: newVolunteer.id, id: newVolunteer.id,
firstname: newVolunteer.firstname, firstname: newVolunteer.firstname,
adult: newVolunteer.adult,
active: newVolunteer.active,
hiddenAsks: newVolunteer.hiddenAsks, hiddenAsks: newVolunteer.hiddenAsks,
pushNotifSubscription: newVolunteer.pushNotifSubscription, pushNotifSubscription: newVolunteer.pushNotifSubscription,
acceptsNotifs: newVolunteer.acceptsNotifs, acceptsNotifs: newVolunteer.acceptsNotifs,
@ -223,11 +248,11 @@ export const volunteerTeamWishesSet = expressAccessor.set(async (list, body, id,
) )
} }
const wishes = body[1] as VolunteerTeamWishes const wishes = body[1] as VolunteerTeamWishes
const volunteer = list.find((v) => v.id === requestedId) const volunteer: Volunteer | undefined = list.find((v) => v.id === requestedId)
if (!volunteer) { if (!volunteer) {
throw Error(`Il n'y a aucun bénévole avec cet identifiant ${requestedId}`) throw Error(`Il n'y a aucun bénévole avec cet identifiant ${requestedId}`)
} }
const newVolunteer = cloneDeep(volunteer) const newVolunteer: Volunteer = cloneDeep(volunteer)
if (wishes.teamWishes !== undefined) { if (wishes.teamWishes !== undefined) {
newVolunteer.teamWishes = wishes.teamWishes newVolunteer.teamWishes = wishes.teamWishes
@ -252,7 +277,7 @@ export const volunteerDayWishesSet = expressAccessor.set(async (list, body, id)
throw Error(`On ne peut acceder qu'à ses propres envies de jours`) throw Error(`On ne peut acceder qu'à ses propres envies de jours`)
} }
const wishes = body[1] as VolunteerDayWishes const wishes = body[1] as VolunteerDayWishes
const volunteer = list.find((v) => v.id === requestedId) const volunteer: Volunteer | undefined = list.find((v) => v.id === requestedId)
if (!volunteer) { if (!volunteer) {
throw Error(`Il n'y a aucun bénévole avec cet identifiant ${requestedId}`) throw Error(`Il n'y a aucun bénévole avec cet identifiant ${requestedId}`)
} }
@ -318,10 +343,8 @@ export const volunteerParticipationDetailsSet = expressAccessor.set(async (list,
} }
}) })
export const volunteerTeamAssignSet = expressAccessor.set(async (list, body, id) => { export const volunteerTeamAssignSet = expressAccessor.set(async (list, body, _id, roles) => {
const requestedId = +body[0] || id if (!roles.includes("répartiteur")) {
const assigner = list.find((v) => v.id === requestedId)
if (!assigner || !assigner.roles.includes("répartiteur")) {
throw Error(`Vous n'avez pas les droits pas assigner les équipes.`) throw Error(`Vous n'avez pas les droits pas assigner les équipes.`)
} }

View File

@ -19,7 +19,7 @@ import certbotRouter from "../routes/certbot"
import { hasSecret, secure } from "./secure" import { hasSecret, secure } from "./secure"
import { announcementListGet } from "./gsheets/announcements" import { announcementListGet } from "./gsheets/announcements"
import { detailedBoxListGet } from "./gsheets/boxes" import { detailedBoxListGet } from "./gsheets/boxes"
import { gameListGet } from "./gsheets/games" import { gameListGet, gameDetailsUpdate } from "./gsheets/games"
import { postulantAdd } from "./gsheets/postulants" import { postulantAdd } from "./gsheets/postulants"
import { teamListGet } from "./gsheets/teams" import { teamListGet } from "./gsheets/teams"
import { import {
@ -100,7 +100,6 @@ app.get("/VolunteerListGet", secure as RequestHandler, volunteerListGet)
// Secured APIs // Secured APIs
app.get("/AnnouncementListGet", secure as RequestHandler, announcementListGet) app.get("/AnnouncementListGet", secure as RequestHandler, announcementListGet)
app.get("/MiscDiscordInvitationGet", secure as RequestHandler, miscDiscordInvitation) app.get("/MiscDiscordInvitationGet", secure as RequestHandler, miscDiscordInvitation)
app.post("/VolunteerSet", secure as RequestHandler, volunteerSet)
app.get("/TeamListGet", teamListGet) app.get("/TeamListGet", teamListGet)
app.get("/VolunteerDiscordId", secure as RequestHandler, volunteerDiscordId) app.get("/VolunteerDiscordId", secure as RequestHandler, volunteerDiscordId)
app.post("/VolunteerAsksSet", secure as RequestHandler, volunteerAsksSet) app.post("/VolunteerAsksSet", secure as RequestHandler, volunteerAsksSet)
@ -114,6 +113,10 @@ app.post("/VolunteerDayWishesSet", secure as RequestHandler, volunteerDayWishesS
app.post("/VolunteerTeamWishesSet", secure as RequestHandler, volunteerTeamWishesSet) app.post("/VolunteerTeamWishesSet", secure as RequestHandler, volunteerTeamWishesSet)
app.post("/VolunteerTeamAssignSet", secure as RequestHandler, volunteerTeamAssignSet) app.post("/VolunteerTeamAssignSet", secure as RequestHandler, volunteerTeamAssignSet)
// Admin only
app.post("/VolunteerSet", secure as RequestHandler, volunteerSet)
app.get("/GameDetailsUpdate", secure as RequestHandler, gameDetailsUpdate)
// Push notification subscription // Push notification subscription
app.post("/notifications/subscribe", notificationsSubscribe) app.post("/notifications/subscribe", notificationsSubscribe)

View File

@ -3,10 +3,6 @@ export class Game {
title = "" title = ""
author = ""
editor = ""
playersMin = 0 playersMin = 0
playersMax = 0 playersMax = 0
@ -27,8 +23,6 @@ export class Game {
export const translationGame: { [k in keyof Game]: string } = { export const translationGame: { [k in keyof Game]: string } = {
id: "id", id: "id",
title: "titre", title: "titre",
author: "auteur",
editor: "editeur",
playersMin: "minJoueurs", playersMin: "minJoueurs",
playersMax: "maxJoueurs", playersMax: "maxJoueurs",
duration: "duree", duration: "duree",

View File

@ -7,3 +7,5 @@ export const gameListGet = serviceAccessors.listGet()
// export const gameGet = serviceAccessors.get() // export const gameGet = serviceAccessors.get()
// export const gameAdd = serviceAccessors.add() // export const gameAdd = serviceAccessors.add()
// export const gameSet = serviceAccessors.set() // export const gameSet = serviceAccessors.set()
export const gameDetailsUpdate = serviceAccessors.securedCustomGet<[], Game[]>("DetailsUpdate")

View File

@ -163,8 +163,6 @@ export interface VolunteerDiscordId {
export interface VolunteerAsks { export interface VolunteerAsks {
id: Volunteer["id"] id: Volunteer["id"]
firstname: Volunteer["firstname"] firstname: Volunteer["firstname"]
adult: Volunteer["adult"]
active: Volunteer["active"]
hiddenAsks: Volunteer["hiddenAsks"] hiddenAsks: Volunteer["hiddenAsks"]
pushNotifSubscription: Volunteer["pushNotifSubscription"] pushNotifSubscription: Volunteer["pushNotifSubscription"]
acceptsNotifs: Volunteer["acceptsNotifs"] acceptsNotifs: Volunteer["acceptsNotifs"]

View File

@ -20,7 +20,8 @@ export const volunteerDiscordIdGet = serviceAccessors.securedCustomGet<
VolunteerDiscordId VolunteerDiscordId
>("DiscordId") >("DiscordId")
export const volunteerPartialAdd = serviceAccessors.customPost<[Partial<Volunteer>]>("PartialAdd") export const volunteerPartialAdd = serviceAccessors.customPost<[Partial<Volunteer>]>("PartialAdd")
export const volunteerSet = serviceAccessors.set()
export const volunteerSet = serviceAccessors.securedCustomPost<[Partial<Volunteer>]>("Set")
export const volunteerLogin = export const volunteerLogin =
serviceAccessors.customPost<[{ email: string; password: string }]>("Login") serviceAccessors.customPost<[{ email: string; password: string }]>("Login")

View File

@ -17,8 +17,6 @@ const mockData: Game[] = [
{ {
id: 5, id: 5,
title: "6 qui prend!", title: "6 qui prend!",
author: "Wolfgang Kramer",
editor: "(uncredited) , Design Edge , B",
playersMin: 2, playersMin: 2,
playersMax: 10, playersMax: 10,
duration: 45, duration: 45,

View File

@ -0,0 +1,50 @@
import { PayloadAction, createSlice, createEntityAdapter } from "@reduxjs/toolkit"
import { StateRequest, toastError, toastSuccess, elementListFetch } from "./utils"
import { Game } from "../services/games"
import { gameDetailsUpdate } from "../services/gamesAccessors"
import { AppState, AppThunk } from "."
const gameAdapter = createEntityAdapter<Game>()
const gameDetailsUpdateSlice = createSlice({
name: "gameDetailsUpdate",
initialState: gameAdapter.getInitialState({
readyStatus: "idle",
} as StateRequest),
reducers: {
getRequesting: (state) => {
state.readyStatus = "request"
},
getSuccess: (state, { payload }: PayloadAction<Game[]>) => {
state.readyStatus = "success"
gameAdapter.setAll(state, payload)
},
getFailure: (state, { payload }: PayloadAction<string>) => {
state.readyStatus = "failure"
state.error = payload
},
},
})
export default gameDetailsUpdateSlice.reducer
export const { getRequesting, getSuccess, getFailure } = gameDetailsUpdateSlice.actions
export const fetchGameDetailsUpdate = elementListFetch(
gameDetailsUpdate,
getRequesting,
getSuccess,
getFailure,
(error: Error) => toastError(`Erreur lors de la modification d'un bénévole: ${error.message}`),
() => toastSuccess("Bénévole modifié !")
)
const shouldFetchGameDetailsUpdate = (state: AppState) =>
state.volunteerList.readyStatus !== "success"
export const fetchGameDetailsUpdateIfNeed = (): AppThunk => (dispatch, getState) => {
const { jwt } = getState().auth
if (shouldFetchGameDetailsUpdate(getState())) return dispatch(fetchGameDetailsUpdate(jwt))
return null
}

View File

@ -5,6 +5,7 @@ import announcementList from "./announcementList"
import auth from "./auth" import auth from "./auth"
import boxList from "./boxList" import boxList from "./boxList"
import gameList from "./gameList" import gameList from "./gameList"
import gameDetailsUpdate from "./gameDetailsUpdate"
import miscDiscordInvitation from "./miscDiscordInvitation" import miscDiscordInvitation from "./miscDiscordInvitation"
import miscMeetingDateList from "./miscMeetingDateList" import miscMeetingDateList from "./miscMeetingDateList"
import postulantAdd from "./postulantAdd" import postulantAdd from "./postulantAdd"
@ -32,6 +33,7 @@ export default (history: History) => ({
auth, auth,
boxList, boxList,
gameList, gameList,
gameDetailsUpdate,
miscDiscordInvitation, miscDiscordInvitation,
miscMeetingDateList, miscMeetingDateList,
postulantAdd, postulantAdd,

View File

@ -118,33 +118,6 @@ export function elementListFetch<Element, ServiceInput extends Array<any>>(
} }
} }
export function elementSet<Element>(
elementSetService: (element: Element) => Promise<{
data?: Element | undefined
error?: Error | undefined
}>,
getRequesting: ActionCreatorWithoutPayload<string>,
getSuccess: ActionCreatorWithPayload<Element, string>,
getFailure: ActionCreatorWithPayload<string, string>,
errorMessage?: (error: Error) => void,
successMessage?: () => void
): (element: Element) => AppThunk {
return (element: Element): AppThunk =>
async (dispatch) => {
dispatch(getRequesting())
const { error, data } = await elementSetService(element)
if (error) {
dispatch(getFailure(error.message))
errorMessage?.(error)
} else {
dispatch(getSuccess(data as Element))
successMessage?.()
}
}
}
export function elementValueFetch<Element>( export function elementValueFetch<Element>(
elementListService: () => Promise<{ elementListService: () => Promise<{
data?: Element | undefined data?: Element | undefined

View File

@ -1,35 +1,38 @@
import { PayloadAction, createSlice, createEntityAdapter } from "@reduxjs/toolkit" import { PayloadAction, createSlice, createSelector } from "@reduxjs/toolkit"
import { StateRequest, toastError, toastSuccess, elementSet } from "./utils" import { StateRequest, toastError, toastSuccess, elementFetch } from "./utils"
import { Volunteer } from "../services/volunteers" import { Volunteer } from "../services/volunteers"
import { volunteerSet } from "../services/volunteersAccessors" import { volunteerSet } from "../services/volunteersAccessors"
import { AppState, AppThunk } from "."
const volunteerAdapter = createEntityAdapter<Volunteer>() type StateVolunteer = { entity?: Volunteer } & StateRequest
export const initialState: StateVolunteer = {
readyStatus: "idle",
}
const volunteerSetSlice = createSlice({ const volunteerSetSlice = createSlice({
name: "volunteerSet", name: "volunteerSet",
initialState: volunteerAdapter.getInitialState({ initialState,
readyStatus: "idle",
} as StateRequest),
reducers: { reducers: {
getRequesting: (state) => { getRequesting: (_) => ({
state.readyStatus = "request" readyStatus: "request",
}, }),
getSuccess: (state, { payload }: PayloadAction<Volunteer>) => { getSuccess: (_, { payload }: PayloadAction<Volunteer>) => ({
state.readyStatus = "success" readyStatus: "success",
volunteerAdapter.setOne(state, payload) entity: payload,
}, }),
getFailure: (state, { payload }: PayloadAction<string>) => { getFailure: (_, { payload }: PayloadAction<string>) => ({
state.readyStatus = "failure" readyStatus: "failure",
state.error = payload error: payload,
}, }),
}, },
}) })
export default volunteerSetSlice.reducer export default volunteerSetSlice.reducer
export const { getRequesting, getSuccess, getFailure } = volunteerSetSlice.actions export const { getRequesting, getSuccess, getFailure } = volunteerSetSlice.actions
export const fetchVolunteerSet = elementSet( export const fetchVolunteerSet = elementFetch(
volunteerSet, volunteerSet,
getRequesting, getRequesting,
getSuccess, getSuccess,
@ -37,3 +40,20 @@ export const fetchVolunteerSet = elementSet(
(error: Error) => toastError(`Erreur lors de la modification d'un bénévole: ${error.message}`), (error: Error) => toastError(`Erreur lors de la modification d'un bénévole: ${error.message}`),
() => toastSuccess("Bénévole modifié !") () => toastSuccess("Bénévole modifié !")
) )
const shouldFetchVolunteerSet = (_state: AppState) => true
export const fetchVolunteerSetIfNeed =
(newPartialVolunteer: Partial<Volunteer>): AppThunk =>
(dispatch, getState) => {
const { jwt } = getState().auth
if (shouldFetchVolunteerSet(getState()))
return dispatch(fetchVolunteerSet(jwt, newPartialVolunteer))
return null
}
export const selectVolunteerSet = createSelector(
(state: AppState) => state,
(state): string | undefined => state.volunteerSet?.entity?.discordId
)

View File

@ -3,6 +3,7 @@ type rolesType = {
} }
const ROLES: rolesType = { const ROLES: rolesType = {
ADMIN: "admin",
ASSIGNER: "répartiteur", ASSIGNER: "répartiteur",
TEAMLEAD: "référent", TEAMLEAD: "référent",
} }

View File

@ -5,7 +5,11 @@ import { selectUserRoles } from "../store/auth"
function withUserRole<T>(requiredRole: string, Component: React.ComponentType<T>) { function withUserRole<T>(requiredRole: string, Component: React.ComponentType<T>) {
return (props: T): JSX.Element => { return (props: T): JSX.Element => {
const roles = useSelector(selectUserRoles) const roles = useSelector(selectUserRoles)
return roles.includes(requiredRole) ? <Component {...props} /> : <div /> return roles.includes(requiredRole) ? (
<Component {...props} />
) : (
<div>Missing role {requiredRole}</div>
)
} }
} }