diff --git a/client/components/Solution.tsx b/client/components/Solution.tsx
index d8e64a2..6fdf23e 100644
--- a/client/components/Solution.tsx
+++ b/client/components/Solution.tsx
@@ -1,21 +1,14 @@
import { useState } from 'preact/hooks'
import { JSX } from 'preact/jsx-runtime'
-import {
- MetadataProps,
- ProblemId,
- Solution as SolutionModel,
- SolutionId,
- SolutionStatus,
- UserId,
-} from '../../shared/model'
+import { ProblemId, Solution as SolutionModel, SolutionId, SolutionStatus, UserId } from '../../shared/model'
import { prependBaseUrl, server } from '../api'
+import { useCurrentUser } from '../hooks/useCurrentUser'
import { Markdown } from './Markdown'
-import { Select } from './Select'
const STATUS_SELECT_OPTIONS: Record
= {
- ['pending']: Soluzione in attesa di correzione
,
- ['correct']: Soluzione corretta
,
- ['wrong']: Soluzione sbagliata
,
+ ['pending']: In attesa di correzione
,
+ ['correct']: Corretta
,
+ ['wrong']: Sbagliata
,
}
type Props = {
@@ -43,6 +36,8 @@ export const Solution = ({
setSolution,
refreshSolution,
}: Props) => {
+ const [user] = useCurrentUser()
+
const markAsCorrect = async () => {
setSolution?.(prevSolution => ({ ...prevSolution, status: 'correct' }))
@@ -73,6 +68,13 @@ export const Solution = ({
refreshSolution?.()
}
+ const deleteSolution = async () => {
+ if (confirm('Sei proprio sicuro di voler eliminare questa soluzione?')) {
+ await server.delete(`/api/solution/${id}`)
+ refreshSolution?.()
+ }
+ }
+
const [viewRaw, setViewRaw] = useState(false)
const toggleViewRaw = () => {
@@ -85,7 +87,9 @@ export const Solution = ({
diff --git a/client/hooks.tsx b/client/hooks.tsx
index eebaf52..883aae5 100644
--- a/client/hooks.tsx
+++ b/client/hooks.tsx
@@ -14,26 +14,6 @@ export const MetadataContext = createContext
({})
export const ServerContext = createContext(false)
export const ClientContext = createContext(false)
-type CurrentUserHook = (onLoaded?: (user: User | null) => void) => [User | null, () => Promise]
-
-export const useCurrentUser: CurrentUserHook = onLoaded => {
- const [user, setUser] = useState(null)
-
- const logout = async () => {
- await server.post('/api/logout')
- setUser(null)
- }
-
- useEffect(() => {
- server.get('/api/current-user').then(user => {
- setUser(user)
- onLoaded?.(user)
- })
- }, [])
-
- return [user, logout]
-}
-
type RefreshFunction = () => AbortController
type HeuristicStateUpdater = StateUpdater
diff --git a/client/hooks/useCurrentUser.tsx b/client/hooks/useCurrentUser.tsx
new file mode 100644
index 0000000..29e4ead
--- /dev/null
+++ b/client/hooks/useCurrentUser.tsx
@@ -0,0 +1,78 @@
+import { ComponentChildren, createContext, JSX } from 'preact'
+import { StateUpdater, useContext, useEffect, useState } from 'preact/hooks'
+import { User } from '../../shared/model'
+import { prependBaseUrl, server } from '../api'
+
+type UserContextValue = {
+ ready: boolean
+ user: User | null
+ setUser: StateUpdater
+ refreshUser: () => Promise
+}
+
+const UserContext = createContext(null)
+
+type CurrentUserHook = () => [User | null, boolean]
+
+export const useCurrentUser: CurrentUserHook = () => {
+ const userContext = useContext(UserContext)
+ if (!userContext) {
+ return [null, false]
+ }
+
+ const { ready, user } = userContext
+ return [user, ready]
+}
+
+type UserFunctionsHook = () => {
+ login: (username: string) => Promise
+ logout: () => Promise
+}
+
+export const useUserFunctions: UserFunctionsHook = () => {
+ const login = async (username: string) => {
+ await fetch(prependBaseUrl(`/api/login`), {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ id: username,
+ }),
+ })
+ await refreshUser()
+ }
+
+ const userContext = useContext(UserContext)
+ if (!userContext) {
+ return { login, logout: async () => {} }
+ }
+
+ const { setUser, refreshUser } = userContext
+
+ const logout = async () => {
+ await server.post('/api/logout')
+ setUser(null)
+ }
+
+ return { login, logout }
+}
+
+export const UserProvider = ({ children }: { children: ComponentChildren }): JSX.Element => {
+ const [ready, setReady] = useState(false)
+ const [user, setUser] = useState(null)
+
+ const refreshUser = async () => {
+ const user = await server.get('/api/current-user')
+ setUser(user)
+ setReady(true)
+ return user
+ }
+
+ // Fetch user when first mounted
+ useEffect(() => {
+ refreshUser()
+ }, [])
+
+ return {children}
+}
diff --git a/client/pages/Admin.tsx b/client/pages/AdminPage.tsx
similarity index 77%
rename from client/pages/Admin.tsx
rename to client/pages/AdminPage.tsx
index 754316d..15e3cbd 100644
--- a/client/pages/Admin.tsx
+++ b/client/pages/AdminPage.tsx
@@ -1,18 +1,30 @@
import { route } from 'preact-router'
import { useState } from 'preact/hooks'
-import { isStudent, Solution as SolutionModel, SolutionId } from '../../shared/model'
+import { isAdministrator, isStudent, Solution as SolutionModel, SolutionId } from '../../shared/model'
import { sortByStringKey } from '../../shared/utils'
import { prependBaseUrl, server } from '../api'
import { Header } from '../components/Header'
import { MarkdownEditor } from '../components/MarkdownEditor'
import { Select } from '../components/Select'
import { Solution } from '../components/Solution'
-import { useCurrentUser, useListResource } from '../hooks'
+import { useListResource } from '../hooks'
+import { useCurrentUser } from '../hooks/useCurrentUser'
const CreateProblem = ({}) => {
const [source, setSource] = useState('')
+
+ type ProblemFields = {
+ title?: string
+ content: string
+ }
+
const createProblem = async () => {
- const id = await server.post('/api/problem', {
+ if (source.trim().length === 0) {
+ alert('Il campo di testo è vuoto!')
+ return
+ }
+
+ const id = await server.post('/api/problem', {
content: source,
})
@@ -21,7 +33,7 @@ const CreateProblem = ({}) => {
return (
<>
-
+
>
)
@@ -30,30 +42,34 @@ const CreateProblem = ({}) => {
type SortOrder = 'latest' | 'oldest'
export const AdminPage = ({}) => {
- const [user] = useCurrentUser(user => {
- if (!user) {
- route(prependBaseUrl('/login'), true)
- } else if (isStudent(user.role)) {
- route(prependBaseUrl('/'), true)
- }
- })
+ const [user, ready] = useCurrentUser()
+ if (!ready) {
+ return <>>
+ }
+ if (user === null) {
+ route(prependBaseUrl('/login'), true)
+ return <>>
+ }
+ if (!isAdministrator(user.role)) {
+ route(prependBaseUrl('/'), true)
+ return <>>
+ }
const [solutions, refreshSolutions, setSolutionHeuristic] = useListResource(`/api/solutions`)
const [sortOrder, setSortOrder] = useState('oldest')
-
const sortedSolutions = sortByStringKey(solutions, s => s.createdAt, sortOrder === 'oldest')
const [trackInteracted, setTrackedInteracted] = useState>(new Set())
-
const hasUntrackedPending = sortedSolutions.filter(s => s.status === 'pending' || trackInteracted.has(s.id)).length > 0
return (
- user && (
+ <>
+
-
Nuovo problema
+
Soluzioni da correggere
{hasUntrackedPending ? (
@@ -90,6 +106,6 @@ export const AdminPage = ({}) => {
>
)}
- )
+ >
)
}
diff --git a/client/pages/Home.tsx b/client/pages/HomePage.tsx
similarity index 55%
rename from client/pages/Home.tsx
rename to client/pages/HomePage.tsx
index 09c2f31..41ace04 100644
--- a/client/pages/Home.tsx
+++ b/client/pages/HomePage.tsx
@@ -1,12 +1,12 @@
-import { route } from 'preact-router'
-import { useContext, useEffect, useState } from 'preact/hooks'
+import { useContext, useState } from 'preact/hooks'
import { Problem as ProblemModel } from '../../shared/model'
import { sortByNumericKey, sortByStringKey } from '../../shared/utils'
import { Header } from '../components/Header'
import { Problem } from '../components/Problem'
import { Select } from '../components/Select'
-import { useResource, useCurrentUser, MetadataContext } from '../hooks'
+import { useResource, MetadataContext } from '../hooks'
+import { useCurrentUser } from '../hooks/useCurrentUser'
function byTime(p: ProblemModel): string {
return p.createdAt
@@ -30,7 +30,6 @@ export const HomePage = () => {
metadata.title = `PHC Problemi`
metadata.description = 'Bacheca di problemi del PHC'
- const [user] = useCurrentUser()
const [problems] = useResource<(ProblemModel & { solutionsCount: number })[]>('/api/problems', [])
const [sortOrder, setSortOrder] = useState
('oldest')
@@ -41,28 +40,30 @@ export const HomePage = () => {
: sortByNumericKey(problems, bySolvedProblems, SORT_ORDER[sortOrder])
return (
-
-
-
-
-
-
+ <>
+
+
+
- {sortedProblems.map(p => (
-
- ))}
-
-
+ {sortedProblems.map(p => (
+
+ ))}
+
+
+ >
)
}
diff --git a/client/pages/Login.tsx b/client/pages/Login.tsx
deleted file mode 100644
index 9d4bbe0..0000000
--- a/client/pages/Login.tsx
+++ /dev/null
@@ -1,45 +0,0 @@
-import { route } from 'preact-router'
-
-import { useState } from 'preact/hooks'
-import { prependBaseUrl } from '../api.js'
-import { Header } from '../components/Header.jsx'
-
-export const LoginPage = () => {
- const [username, setUsername] = useState('')
-
- const login = async () => {
- // @ts-ignore
- await fetch(prependBaseUrl(`/api/login`), {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify({
- id: username,
- }),
- })
-
- route(prependBaseUrl('/'))
- }
-
- return (
-
-
- Accedi
-
-
- )
-}
diff --git a/client/pages/LoginPage.tsx b/client/pages/LoginPage.tsx
new file mode 100644
index 0000000..76ee8cd
--- /dev/null
+++ b/client/pages/LoginPage.tsx
@@ -0,0 +1,39 @@
+import { route } from 'preact-router'
+
+import { useState } from 'preact/hooks'
+import { prependBaseUrl } from '../api.js'
+import { Header } from '../components/Header.jsx'
+import { useUserFunctions } from '../hooks/useCurrentUser.js'
+
+export const LoginPage = () => {
+ const [username, setUsername] = useState('')
+ const { login } = useUserFunctions()
+
+ const handleLogin = async () => {
+ await login(username)
+ route(prependBaseUrl('/'))
+ }
+
+ return (
+ <>
+
+
+ Accedi
+
+
+ >
+ )
+}
diff --git a/client/pages/Problem.tsx b/client/pages/Problem.tsx
deleted file mode 100644
index fbd7996..0000000
--- a/client/pages/Problem.tsx
+++ /dev/null
@@ -1,76 +0,0 @@
-import { route } from 'preact-router'
-import { useContext, useState } from 'preact/hooks'
-import { isAdministrator, Problem as ProblemModel, Solution as SolutionModel } from '../../shared/model'
-import { prependBaseUrl, server } from '../api'
-import { Header } from '../components/Header'
-import { MarkdownEditor } from '../components/MarkdownEditor'
-import { Problem } from '../components/Problem'
-import { Solution } from '../components/Solution'
-import { MetadataContext, useCurrentUser, useListResource, useResource } from '../hooks'
-
-type RouteProps = {
- id: string
-}
-
-export const ProblemPage = ({ id }: RouteProps) => {
- const metadata = useContext(MetadataContext)
- metadata.title = `Problem ${id}`
- metadata.description = 'Bacheca di problemi del PHC'
-
- const [user] = useCurrentUser()
-
- const [source, setSource] = useState('')
-
- const [problem] = useResource
(`/api/problem/${id}`, null, problem => {
- if (problem === null) {
- route(prependBaseUrl('/'))
- }
- })
-
- const content = problem?.content ?? ''
-
- const [solutions, refreshSolutions, setSolutionHeuristic] = useListResource(
- `/api/solutions?problem=${id}`
- )
-
- const sendSolution = async () => {
- await server.post('/api/solution', {
- forProblem: id,
- content: source,
- })
-
- setSource('')
-
- refreshSolutions()
- }
-
- return (
-
-
- Testo del problema
-
- {solutions.length > 0 && (
-
- Soluzioni
-
- {solutions.map((s, index) => (
- setSolutionHeuristic(index, solFn)}
- />
- ))}
-
-
- )}
- {user && (
- <>
- Invia una soluzione al problema
-
-
- >
- )}
-
- )
-}
diff --git a/client/pages/ProblemPage.tsx b/client/pages/ProblemPage.tsx
new file mode 100644
index 0000000..fdc1201
--- /dev/null
+++ b/client/pages/ProblemPage.tsx
@@ -0,0 +1,165 @@
+import { route } from 'preact-router'
+import { useContext, useEffect, useState } from 'preact/hooks'
+import { isAdministrator, Problem as ProblemModel, Solution as SolutionModel } from '../../shared/model'
+import { prependBaseUrl, server } from '../api'
+import { Header } from '../components/Header'
+import { MarkdownEditor } from '../components/MarkdownEditor'
+import { Problem } from '../components/Problem'
+import { Solution } from '../components/Solution'
+import { MetadataContext, useListResource, useResource } from '../hooks'
+import { useCurrentUser } from '../hooks/useCurrentUser'
+
+type RouteProps = {
+ id: string
+}
+
+export const ProblemPage = ({ id }: RouteProps) => {
+ const metadata = useContext(MetadataContext)
+ metadata.title = `Problem ${id}`
+ metadata.description = 'Bacheca di problemi del PHC'
+
+ const [user] = useCurrentUser()
+
+ const [source, setSource] = useState('')
+
+ const [problem, refreshProblem] = useResource(`/api/problem/${id}`, null, problem => {
+ if (problem === null) {
+ route(prependBaseUrl('/'))
+ }
+ })
+
+ const [solutions, refreshSolutions, setSolutionHeuristic] = useListResource(`/api/solutions?problem=${id}`)
+ const userSolutions: [number, SolutionModel][] = solutions.flatMap((s, index) => (user && s.sentBy === user.id ? [[index, s]] : []))
+ const otherSolutions: [number, SolutionModel][] = solutions.flatMap((s, index) => (user && s.sentBy !== user.id ? [[index, s]] : []))
+
+ const notLoggedSolutions: [number, SolutionModel][] = user ? [] : solutions.map((s, index) => [index, s])
+
+ const sendSolution = async () => {
+ if (source.trim().length === 0) {
+ alert('La soluzione che hai inserito è vuota!')
+ return
+ }
+
+ await server.post('/api/solution', {
+ forProblem: id,
+ content: source,
+ })
+
+ setSource('')
+
+ refreshSolutions()
+ }
+
+ const [editing, setEditing] = useState(false)
+ const [modifiedProblemSource, setModifiedProblemSource] = useState('')
+
+ useEffect(() => {
+ if (problem) {
+ setModifiedProblemSource(problem.content)
+ }
+ }, [problem?.content])
+
+ const updateProblem = async () => {
+ await server.patch(`/api/problem/${id}`, {
+ content: modifiedProblemSource,
+ })
+ refreshProblem()
+ setEditing(false)
+ }
+
+ const deleteProblem = async () => {
+ if (confirm('Sei veramente sicuro di voler eliminare questo problema?')) {
+ await server.delete(`/api/problem/${id}`)
+ route(prependBaseUrl('/'))
+ }
+ }
+
+ return (
+ <>
+
+
+ Testo del problema
+ {!editing ? (
+ <>
+ {problem && }
+ {user && isAdministrator(user.role) && (
+ <>
+
+
+ >
+ )}
+ >
+ ) : (
+ <>
+
+
+
+
+
+ >
+ )}
+ {userSolutions.length > 0 && (
+
+ Le tue soluzioni
+
+ {userSolutions.map(([index, s]) => (
+ setSolutionHeuristic(index, solFn)}
+ />
+ ))}
+
+
+ )}
+ {otherSolutions.length > 0 && (
+
+ Altre soluzioni
+
+ {otherSolutions.map(([index, s]) => (
+ setSolutionHeuristic(index, solFn)}
+ />
+ ))}
+
+
+ )}
+ {notLoggedSolutions.length > 0 && (
+
+ Soluzioni
+
+ {notLoggedSolutions.map(([index, s]) => (
+ setSolutionHeuristic(index, solFn)}
+ />
+ ))}
+
+
+ )}
+ {user && (
+ <>
+
+ Invia una soluzione al problema
+
+
+
+ warning
+ Attenzione, una soluzione inviata non può essere modificata!
+
+ >
+ )}
+
+ >
+ )
+}
diff --git a/client/pages/Profile.tsx b/client/pages/Profile.tsx
deleted file mode 100644
index e76ead5..0000000
--- a/client/pages/Profile.tsx
+++ /dev/null
@@ -1,45 +0,0 @@
-import { route } from 'preact-router'
-import { useState } from 'preact/hooks'
-import { isAdministrator, Solution as SolutionModel, User } from '../../shared/model'
-import { prependBaseUrl, server } from '../api'
-import { Header } from '../components/Header'
-import { Select } from '../components/Select'
-import { Solution } from '../components/Solution'
-import { useCurrentUser, useResource } from '../hooks'
-
-const SolutionList = ({ user }: { user: User }) => {
- const [solutions, refresh] = useResource(`/api/solutions?user=${user.id}`, [])
-
- return (
-
- {solutions.map(solution => (
-
- ))}
-
- )
-}
-
-export const ProfilePage = ({}) => {
- const [user, logout] = useCurrentUser(user => {
- if (!user) {
- route(prependBaseUrl('/login'), true)
- }
- })
-
- const handleLogout = async () => {
- await logout()
- route(prependBaseUrl('/'))
- }
-
- return (
- user && (
-
-
- Profilo
-
- Le tue soluzioni
-
-
- )
- )
-}
diff --git a/client/pages/ProfilePage.tsx b/client/pages/ProfilePage.tsx
new file mode 100644
index 0000000..0f5ed7e
--- /dev/null
+++ b/client/pages/ProfilePage.tsx
@@ -0,0 +1,45 @@
+import { route } from 'preact-router'
+import { isAdministrator, Solution as SolutionModel, User } from '../../shared/model'
+import { sortByStringKey } from '../../shared/utils'
+import { prependBaseUrl } from '../api'
+import { Header } from '../components/Header'
+import { Solution } from '../components/Solution'
+import { useResource } from '../hooks'
+import { useCurrentUser, useUserFunctions } from '../hooks/useCurrentUser'
+
+export const ProfilePage = ({}) => {
+ const [user, ready] = useCurrentUser()
+ if (!ready) {
+ return <>>
+ }
+ if (!user) {
+ route(prependBaseUrl('/login'), true)
+ return <>>
+ }
+
+ const { logout } = useUserFunctions()!
+
+ const handleLogout = async () => {
+ await logout()
+ route(prependBaseUrl('/'))
+ }
+
+ const [solutions, refreshSolutions] = useResource(`/api/solutions?user=${user.id}`, [])
+ const sortedSolutions = sortByStringKey(solutions, s => s.createdAt, false)
+
+ return (
+ <>
+
+
+ Profilo
+
+ Le tue soluzioni
+
+ {sortedSolutions.map(solution => (
+
+ ))}
+
+
+ >
+ )
+}
diff --git a/client/styles/main.scss b/client/styles/main.scss
index 4512c01..d38f15c 100644
--- a/client/styles/main.scss
+++ b/client/styles/main.scss
@@ -150,7 +150,7 @@ button {
&:disabled {
cursor: default;
- opacity: 0.6;
+ opacity: 0.4;
filter: grayscale(1);
@@ -189,8 +189,30 @@ a:visited {
// Typography
//
+.icon-text {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+hr {
+ width: 100%;
+ max-width: 100ch;
+ height: 1px;
+ background: #ccc;
+ border: none;
+ margin: 0;
+}
+
+.vr {
+ height: 100%;
+ width: 1px;
+ background: #ccc;
+}
+
.dotted {
- text-decoration: underline dotted gray;
+ cursor: help;
+ border-bottom: 2px dotted #555;
}
.math-inline {
@@ -275,8 +297,15 @@ main {
gap: 2rem;
+ .col {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 0.5rem;
+ }
+
.subtitle {
- font-size: 22px;
+ font-size: 28px;
}
.fill-main {
@@ -347,6 +376,12 @@ header {
width: 100%;
position: relative;
+ padding: 2rem 0;
+
+ // border-bottom: 1px solid #ddd;
+ box-shadow: 0 0 0.5rem 0 #00000028;
+ background: #fdfdfd;
+
.logo {
font-size: 42px;
font-family: 'Lato';
@@ -360,7 +395,7 @@ header {
nav {
position: absolute;
- right: 0;
+ right: 2rem;
display: flex;
gap: 1rem;
@@ -410,6 +445,8 @@ header {
.problem-content {
@extend .text-body;
+
+ flex-grow: 1;
}
.problem-footer {
@@ -504,6 +541,7 @@ header {
}
.status-label {
+ user-select: none;
text-align: right;
}
}
@@ -599,7 +637,7 @@ header {
// On mobile
@media screen and (max-width: $device-s-width), (pointer: coarse) {
main {
- padding: 1rem 1rem 6rem;
+ padding: 2rem 1rem 6rem;
&.page-home {
.board {
@@ -614,6 +652,7 @@ header {
align-items: center;
gap: 2rem;
+ padding: 1rem;
nav {
position: relative;
diff --git a/server.ts b/server.ts
index f2a2d09..9dd322f 100644
--- a/server.ts
+++ b/server.ts
@@ -52,23 +52,14 @@ async function createDevRouter() {
const { html, metadata } = render(req.originalUrl)
- process.stdout.write('[Metadata] ')
- console.dir(metadata)
-
const fullUrl = `${req.protocol}://${req.get('host')}${req.originalUrl}`
const metaTagsHtml =
'' +
(metadata.title ? `\n` : '') +
- (metadata.description
- ? `\n`
- : '') +
+ (metadata.description ? `\n` : '') +
`\n`
- res.send(
- transformedTemplate
- .replace('', metaTagsHtml)
- .replace('', html)
- )
+ res.send(transformedTemplate.replace('', metaTagsHtml).replace('', html))
} catch (error: any) {
vite.ssrFixStacktrace(error)
next(error)
@@ -95,23 +86,14 @@ async function createProductionRouter() {
const { html, metadata } = render(req.originalUrl)
- process.stdout.write('[Metadata] ')
- console.dir(metadata)
-
const fullUrl = `${req.protocol}://${req.get('host')}${req.originalUrl}`
const metaTagsHtml =
'' +
(metadata.title ? `\n` : '') +
- (metadata.description
- ? `\n`
- : '') +
+ (metadata.description ? `\n` : '') +
`\n`
- res.send(
- transformedTemplate
- .replace('', metaTagsHtml)
- .replace('', html)
- )
+ res.send(transformedTemplate.replace('', metaTagsHtml).replace('', html))
})
r.use('/', express.static('dist/entry-client'))
@@ -126,7 +108,7 @@ async function createProductionRouter() {
async function main() {
const app = express()
- app.use(morgan('dev'))
+ app.use(morgan('[Request] :method :url :status :response-time ms - :res[content-length]'))
app.use('/', await createApiRouter())
@@ -137,7 +119,7 @@ async function main() {
}
app.listen(config.port, () => {
- console.log(`Listening on port ${config.port}...`)
+ console.log(`[Server] Listening on port ${config.port}...`)
})
}
diff --git a/server/db/database.ts b/server/db/database.ts
index 177c179..e6feedb 100644
--- a/server/db/database.ts
+++ b/server/db/database.ts
@@ -2,15 +2,7 @@ import crypto from 'crypto'
import { readFile, writeFile, access, constants } from 'fs/promises'
-import {
- MetadataProps as MetaProps,
- Problem,
- ProblemId,
- Solution,
- SolutionId,
- User,
- UserId,
-} from '../../shared/model'
+import { MetadataProps as MetaProps, Problem, ProblemId, Solution, SolutionId, User, UserId } from '../../shared/model'
function once any>(fn: T, message: string): T {
let flag = false
@@ -36,23 +28,23 @@ function createMutex(): Mutex {
const unlock = () => {
if (waiters.length > 0) {
- console.log(`[Mutex] Passing lock to next in queue (of size ${waiters.length})`)
+ console.log(`[Database/Mutex] Passing lock to next in queue (of size ${waiters.length})`)
const resolve = waiters.shift()!!
resolve(once(unlock, `lock already released`))
} else {
locked = false
- console.log(`[Mutex] Releasing the lock`)
+ console.log(`[Database/Mutex] Releasing the lock`)
}
}
const lock = (): Promise => {
if (locked) {
- console.log(`[Mutex] Putting into queue`)
+ console.log(`[Database/Mutex] Putting into queue`)
return new Promise(resolve => {
waiters.push(resolve)
})
} else {
- console.log(`[Mutex] Acquiring the lock`)
+ console.log(`[Database/Mutex] Acquiring the lock`)
locked = true
return Promise.resolve(once(unlock, `lock already released`))
}
@@ -81,10 +73,7 @@ export function createDatabase(path: string, initialValue: Database) {
}
}
-async function withDatabase(
- { path, initialValue, mu }: DatabaseConnection,
- fn: (db: Database) => R | Promise
-): Promise {
+async function withDatabase({ path, initialValue, mu }: DatabaseConnection, fn: (db: Database) => R | Promise): Promise {
const unlock: Lock = await mu.lock()
try {
@@ -125,18 +114,19 @@ export const getUser: (db: DatabaseConnection, id: string) => Promise
-): Promise =>
+export const createProblem = (db: DatabaseConnection, { title, content, createdBy }: Omit): Promise =>
withDatabase(db, state => {
- const nextId = (Object.keys(state.problems).length + 1).toString() as ProblemId
+ const problemIds = Object.keys(state.problems)
+ const nextId = (problemIds.length > 0 ? String(parseInt(problemIds.at(-1)!) + 1) : '1') as ProblemId
state.problems[nextId] = {
id: nextId,
+ createdAt: new Date().toJSON(),
+ deleted: false,
+
+ title,
content,
createdBy,
- createdAt: new Date().toJSON(),
}
return nextId
@@ -144,12 +134,28 @@ export const createProblem = (
export const getProblem = (db: DatabaseConnection, id: string): Promise =>
withDatabase(db, state => {
- return state.problems[id] ?? null
+ const problem = (state.problems[id] ?? null) as Problem | null
+ return problem && !problem.deleted ? problem : null
})
export const getProblems = (db: DatabaseConnection): Promise =>
withDatabase(db, state => {
- return Object.values(state.problems)
+ return Object.values(state.problems).filter(p => !p.deleted)
+ })
+
+export const updateProblem = (db: DatabaseConnection, id: ProblemId, problem: Partial>): Promise =>
+ withDatabase(db, state => {
+ state.problems[id] = {
+ ...state.problems[id],
+ ...problem,
+ }
+
+ return state.problems[id]
+ })
+
+export const deleteProblem = (db: DatabaseConnection, id: ProblemId): Promise =>
+ withDatabase(db, state => {
+ state.problems[id].deleted = true
})
//
@@ -166,6 +172,7 @@ export const createSolution = (
state.solutions[id] = {
id,
createdAt: new Date().toISOString(),
+ deleted: false,
sentBy,
forProblem,
@@ -179,14 +186,23 @@ export const createSolution = (
export const getSolution = (db: DatabaseConnection, id: SolutionId): Promise =>
withDatabase(db, state => {
- return state.solutions[id] ?? null
+ const solution = (state.solutions[id] ?? null) as Solution | null
+ return solution && !solution.deleted ? solution : null
})
-export const updateSolution = (
- db: DatabaseConnection,
- id: SolutionId,
- solution: Partial>
-): Promise =>
+export const getSolutions = (db: DatabaseConnection) =>
+ withDatabase(db, state => {
+ return Object.values(state.solutions).filter(s => !s.deleted)
+ })
+
+export const getVisibleSolutions = (db: DatabaseConnection): Promise =>
+ withDatabase(db, state => {
+ return Object.values(state.solutions)
+ .filter(s => !s.deleted)
+ .filter(s => s.visible)
+ })
+
+export const updateSolution = (db: DatabaseConnection, id: SolutionId, solution: Partial>): Promise =>
withDatabase(db, state => {
state.solutions[id] = {
...state.solutions[id],
@@ -196,14 +212,7 @@ export const updateSolution = (
return state.solutions[id]
})
-export const getSolutions = (db: DatabaseConnection) =>
- withDatabase(db, state => {
- let solutions = Object.values(state.solutions)
-
- return solutions
- })
-
-export const getVisibleSolutions = (db: DatabaseConnection) =>
+export const deleteSolution = (db: DatabaseConnection, id: SolutionId): Promise =>
withDatabase(db, state => {
- return Object.values(state.solutions).filter(s => s.visible)
+ state.solutions[id].deleted = true
})
diff --git a/server/middlewares.ts b/server/middlewares.ts
index d52a019..bfd4e6a 100644
--- a/server/middlewares.ts
+++ b/server/middlewares.ts
@@ -52,10 +52,6 @@ interface AuthenticatedRequest extends Request {
user: User | null
}
-export const authenticatedMiddleware = (
- req: AuthenticatedRequest,
- res: Response,
- next: NextFunction
-) => {
+export const authenticatedMiddleware = (req: AuthenticatedRequest, res: Response, next: NextFunction) => {
req.user && next()
}
diff --git a/server/routes.ts b/server/routes.ts
index 1b64287..e9cdc3d 100644
--- a/server/routes.ts
+++ b/server/routes.ts
@@ -3,9 +3,7 @@ import crypto from 'crypto'
import bodyParser from 'body-parser'
import cookieParser from 'cookie-parser'
-import express, { Request, Response, Router } from 'express'
-
-import { createStatusRouter } from './middlewares'
+import express, { Request, Router } from 'express'
import { StatusCodes } from 'http-status-codes'
@@ -13,13 +11,15 @@ import {
createDatabase,
createProblem,
createSolution,
+ deleteProblem,
+ deleteSolution,
getProblem,
getProblems,
getSolution,
getSolutions,
getUser,
getUsers,
- getVisibleSolutions,
+ updateProblem,
updateSolution,
} from './db/database'
@@ -28,10 +28,11 @@ import {
isAdministrator,
isStudent,
Opaque,
- Problem,
+ Problem as ProblemModel,
ProblemId,
Solution as SolutionModel,
SolutionId,
+ User as UserModel,
UserId,
} from '../shared/model'
import { initialDatabaseValue } from './db/example-data'
@@ -55,14 +56,12 @@ export async function createApiRouter() {
const db = createDatabase(process.env.DATABASE_PATH ?? './db.local.json', initialDatabaseValue)
- async function getRequestUser(req: Request) {
+ async function getRequestUser(req: Request): Promise {
const userId = sessions.getUserForSession(req.cookies.sid)
if (!userId) {
return null
}
- console.log(userId)
-
return await getUser(db, userId)
}
@@ -71,8 +70,9 @@ export async function createApiRouter() {
r.use(bodyParser.json())
r.use(cookieParser())
- r.use('/api/status', createStatusRouter())
- // r.use('/api/ping', new PingRouter())
+ r.get('/api/status', (req, res) => {
+ res.json({ url: req.originalUrl, status: 'ok' })
+ })
r.get('/api/current-user', async (req, res) => {
res.json(await getRequestUser(req))
@@ -108,7 +108,7 @@ export async function createApiRouter() {
})
r.get('/api/problems', async (req, res) => {
- type ProblemWithSolutionsCount = Problem & { solutionsCount?: number }
+ type ProblemWithSolutionsCount = ProblemModel & { solutionsCount?: number }
const problems: ProblemWithSolutionsCount[] = await getProblems(db)
const solutions = await getSolutions(db)
@@ -131,18 +131,76 @@ export async function createApiRouter() {
res.json(await getProblem(db, req.params.id))
})
+ r.patch('/api/problem/:id', async (req, res) => {
+ const id = req.params.id as ProblemId
+
+ const user = await getRequestUser(req)
+ // l'utente deve essere loggato
+ if (!user) {
+ res.sendStatus(StatusCodes.UNAUTHORIZED)
+ return
+ }
+
+ // l'utente deve essere un admin o un moderatore
+ if (!isAdministrator(user.role)) {
+ res.sendStatus(StatusCodes.UNAUTHORIZED)
+ return
+ }
+
+ // uno studente può modificare solo il campo "content"
+ if (user.role === 'moderator' && !validateObjectKeys(req.body, ['content'])) {
+ res.status(StatusCodes.UNAUTHORIZED)
+ res.send(`a moderator can only modify the field "content"`)
+ return
+ }
+
+ await updateProblem(db, id, req.body)
+
+ res.send({ status: 'ok' })
+ })
+
+ r.delete('/api/problem/:id', async (req, res) => {
+ const id = req.params.id as ProblemId
+
+ const user = await getRequestUser(req)
+ if (!user) {
+ res.sendStatus(StatusCodes.UNAUTHORIZED)
+ return
+ }
+
+ if (user.role !== 'admin') {
+ res.status(StatusCodes.UNAUTHORIZED)
+ res.send(`only an admin can delete this entity`)
+ return
+ }
+
+ await deleteProblem(db, id)
+
+ res.send({ status: 'ok' })
+ })
+
r.post('/api/problem', async (req, res) => {
const user = await getRequestUser(req)
+ // l'utente deve essere loggato
if (!user) {
res.sendStatus(StatusCodes.UNAUTHORIZED)
return
}
+ // Solo un amministratore può inviare nuovi problemi
if (user.role !== 'admin' && user.role !== 'moderator') {
res.sendStatus(StatusCodes.UNAUTHORIZED)
return
}
+ // Il contenuto del problema deve essere non vuoto
+ if (req.body.content.trim().length === 0) {
+ res.sendStatus(StatusCodes.UNPROCESSABLE_ENTITY)
+ return
+ }
+
+ const problemNextId = (await getProblems(db)).map(p => parseInt(p.id)).reduce((acc, v) => Math.max(acc, v), 1) + 1
const id = await createProblem(db, {
+ title: req.body.title ?? `Problema ${problemNextId}`,
content: req.body.content,
createdBy: user.id,
})
@@ -236,10 +294,7 @@ export async function createApiRouter() {
return
}
// un moderatore può modificare solo i campi "content", "visible", "status"
- if (
- user.role === 'moderator' &&
- !validateObjectKeys(req.body, ['content', 'status', 'visible'])
- ) {
+ if (user.role === 'moderator' && !validateObjectKeys(req.body, ['content', 'status', 'visible'])) {
res.status(StatusCodes.UNAUTHORIZED)
res.send(`a moderator can only modify the fields "content", "visible", "status"`)
return
@@ -250,6 +305,35 @@ export async function createApiRouter() {
res.json({ status: 'ok' })
})
+ r.delete('/api/solution/:id', async (req, res) => {
+ const id = req.params.id as SolutionId
+
+ const user = await getRequestUser(req)
+ // l'utente deve essere loggato
+ if (!user) {
+ res.sendStatus(StatusCodes.UNAUTHORIZED)
+ return
+ }
+
+ const solution = await getSolution(db, id)
+
+ // la soluzione deve esistere
+ if (solution === null) {
+ res.sendStatus(StatusCodes.NOT_FOUND)
+ return
+ }
+
+ // solo un admin può eliminare le soluzioni degli utenti
+ if (user.role !== 'admin' && solution.sentBy !== user.id) {
+ res.sendStatus(StatusCodes.UNAUTHORIZED)
+ return
+ }
+
+ await deleteSolution(db, id)
+
+ res.send({ status: 'ok' })
+ })
+
r.get('/api/user/:id', async (req, res) => {
const user = await getRequestUser(req)
diff --git a/shared/model.ts b/shared/model.ts
index cad1010..f52c77e 100644
--- a/shared/model.ts
+++ b/shared/model.ts
@@ -2,7 +2,7 @@
// Common
//
-export type MetadataProps = 'id' | 'createdAt'
+export type MetadataProps = 'id' | 'createdAt' | 'deleted'
export type Opaque = T & { _: K; __: L }
export type Id = Opaque
@@ -41,7 +41,9 @@ export type ProblemId = Id
export type Problem = {
id: ProblemId
createdAt: string
+ deleted: boolean
+ title: string
content: string
createdBy: UserId
}
@@ -57,6 +59,7 @@ export type SolutionId = Id
export type Solution = {
id: SolutionId
createdAt: string
+ deleted: boolean
sentBy: UserId
forProblem: ProblemId