Skip to content

Commit 4f58040

Browse files
committed
Ratings can be cast by users in the frontend
1 parent 8c7adab commit 4f58040

6 files changed

Lines changed: 96 additions & 69 deletions

File tree

backend/src/controllers/dto.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -620,14 +620,16 @@ export class RatingDTO {
620620
@Expose()
621621
@Type(() => CriterionDTO)
622622
@ValidateNested()
623-
public criteria!: CriterionDTO;
623+
public criterion!: CriterionDTO;
624624
@Expose()
625625
@IsInt()
626626
@Min(1)
627627
@Max(5)
628628
public rating!: number;
629629
}
630630

631+
// Do not send all ratings to the client,
632+
// because peoples opinion on the projects should be anonymous
631633
export class ProjectRatingResultDTO {
632634
@Expose()
633635
@Type(() => ProjectDTO)

backend/src/controllers/rating-controller.ts

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@ import {
44
CurrentUser,
55
Get,
66
Post,
7-
Body
7+
Body,
8+
Param
89
} from "routing-controllers";
910
import { Inject } from "typedi";
1011
import { UserRole } from "../entities/user-role";
@@ -26,13 +27,16 @@ export class RatingController {
2627
) {}
2728

2829
/**
29-
* Get all ratings.
30+
* Get aggregated rating results grouped by project and criteria.
3031
*/
31-
@Get("/")
32-
@Authorized(UserRole.Root)
33-
public async getAllRatings(): Promise<RatingDTO[]> {
34-
const ratings = await this._ratings.getAllRatings();
35-
return ratings.map((r) => convertBetweenEntityAndDTO(r, RatingDTO));
32+
@Get("/by-project/:id")
33+
@Authorized(UserRole.User)
34+
public async getUsersRatingsForProject(
35+
@Param("id") projectId: number,
36+
@CurrentUser() user: User,
37+
): Promise<RatingDTO[]> {
38+
const results = await this._ratings.getUsersRatingsForProject(projectId, user);
39+
return results.map((r) => convertBetweenEntityAndDTO(r, RatingDTO));
3640
}
3741

3842
/**
@@ -50,7 +54,7 @@ export class RatingController {
5054
*/
5155
@Post("/rate")
5256
@Authorized(UserRole.User)
53-
public async createRating(
57+
public async rate(
5458
@Body() { data: ratingDTO }: { data: RatingDTO },
5559
@CurrentUser() user: User,
5660
): Promise<RatingDTO> {
@@ -60,7 +64,7 @@ export class RatingController {
6064
// write the requesting user into it.
6165
rating.user = user;
6266

63-
const createdRating = await this._ratings.createRating(rating, user);
67+
const createdRating = await this._ratings.upsertRating(rating, user);
6468
return convertBetweenEntityAndDTO(createdRating, RatingDTO);
6569
}
6670
}

backend/src/services/rating-service.ts

Lines changed: 52 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { User } from "../entities/user";
1010
import { Team } from "../entities/team";
1111
import { Project } from "../entities/project";
1212
import { Criterion } from "../entities/criterion";
13+
import { UserRole } from "../entities/user-role";
1314

1415
export interface ProjectRatingResult {
1516
project: Project;
@@ -18,21 +19,18 @@ export interface ProjectRatingResult {
1819

1920
export interface IRatingService extends IService {
2021
/**
21-
* Get all ratings
22+
* Get the ratings for a specific project, cast by a specific user.
23+
* Users may only read their own created ratings.
2224
*/
23-
getAllRatings(): Promise<readonly Rating[]>;
25+
getUsersRatingsForProject(projectId: number, user: User): Promise<readonly Rating[]>;
2426
/**
25-
* Create new rating
27+
* Upsert a rating
2628
*/
27-
createRating(rating: Rating, user: User): Promise<Rating>;
28-
/**
29-
* Update rating
30-
*/
31-
updateRating(rating: Rating, user: User): Promise<Rating>;
29+
upsertRating(rating: Rating, user: User): Promise<Rating>;
3230
/**
3331
* Get rating by id
3432
*/
35-
getRatingByID(id: number): Promise<RatingDTO | undefined>;
33+
getRatingByID(id: number, user: User): Promise<RatingDTO | undefined>;
3634
/**
3735
* Delete single rating by id
3836
*/
@@ -74,49 +72,48 @@ export class RatingService implements IRatingService {
7472
}
7573

7674
/**
77-
* Gets all ratings.
78-
*/
79-
public async getAllRatings(): Promise<readonly Rating[]> {
80-
return this._database.getRepository(Rating).find();
81-
}
82-
83-
/**
84-
* Updates a rating.
85-
* @param rating The rating to update
75+
* Get the ratings for a specific project, cast by a specific user.
76+
* Users may only read their own created ratings.
8677
*/
87-
public async updateRating(rating: Rating, user: User): Promise<Rating> {
88-
const originRating = await this._ratings.findOneBy({ id: rating.id });
89-
90-
if (!originRating) {
91-
throw new NotFoundError("Rating not found");
92-
}
93-
94-
if (user.id !== originRating.user.id) {
95-
throw new ForbiddenError("You can only update your own ratings");
96-
}
97-
98-
await this.checkPermission(rating, user);
99-
100-
return this._ratings.save(rating);
78+
public async getUsersRatingsForProject(projectId: number, user: User): Promise<readonly Rating[]> {
79+
// TODO test
80+
return this._database.getRepository(Rating).find({
81+
where: {
82+
project: {
83+
id: projectId
84+
},
85+
user: {
86+
id: user.id
87+
}
88+
}
89+
});
10190
}
10291

10392
/**
104-
* Creates a rating.
93+
* Upsert a rating.
10594
* @param rating The rating to create
10695
*/
107-
public async createRating(rating: Rating, user: User): Promise<Rating> {
96+
public async upsertRating(rating: Rating, user: User): Promise<Rating> {
10897
await this.checkPermission(rating, user);
10998

110-
const existing = await this._ratings.findOne({
111-
where: {
112-
user: { id: user.id },
113-
project: { id: rating.project.id },
114-
criterion: { id: rating.criterion.id },
99+
const existingRating = await this._ratings.findOneBy({
100+
user: {
101+
id: user.id
115102
},
103+
project: {
104+
id: rating.project.id
105+
},
106+
criterion: {
107+
id: rating.criterion.id
108+
}
116109
});
117110

118-
if (existing) {
119-
throw new BadRequestError("You have already rated this project for this criterion");
111+
if (existingRating !== null) {
112+
// Update
113+
return this._ratings.save({
114+
...rating,
115+
id: existingRating.id
116+
});
120117
}
121118

122119
return this._ratings.save(rating);
@@ -126,8 +123,17 @@ export class RatingService implements IRatingService {
126123
* Gets a rating by its id.
127124
* @param id The id of the rating
128125
*/
129-
public async getRatingByID(id: number): Promise<RatingDTO | undefined> {
126+
public async getRatingByID(id: number, user: User): Promise<RatingDTO | undefined> {
130127
const rating = await this._ratings.findOneBy({ id });
128+
129+
if (!rating) {
130+
throw new NotFoundError("Rating not found");
131+
}
132+
133+
if (rating.user.id !== user.id && user.role !== UserRole.Root) {
134+
throw new ForbiddenError()
135+
}
136+
131137
return rating ? convertBetweenEntityAndDTO(rating, RatingDTO) : undefined;
132138
}
133139

@@ -196,6 +202,9 @@ export class RatingService implements IRatingService {
196202
return result;
197203
}
198204

205+
/**
206+
* Check if the user is permitted to create/modify/delete this rating.
207+
*/
199208
private async checkPermission(rating: Rating, user: User): Promise<void> {
200209
const settings = await this._settings.getSettings();
201210
if (!settings.application.allowRatingProjects) {
@@ -207,7 +216,7 @@ export class RatingService implements IRatingService {
207216
throw new NotFoundError("Project not found");
208217
}
209218
if (!project.allowRating) {
210-
throw new ForbiddenError("Rating this project is not allowed")
219+
throw new ForbiddenError("Rating this project is not allowed");
211220
}
212221

213222
const team = await this._teams.findOneBy({ id: project.team.id })

frontend/src/api/index.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -629,8 +629,10 @@ export class ApiClient {
629629
/**
630630
* Gets all ratings.
631631
*/
632-
public async getAllRatings(): Promise<readonly RatingDTO[]> {
633-
return await this.get<RatingControllerMethods["getAllRatings"]>("/ratings");
632+
public async getUsersRatingsForProject(project): Promise<readonly RatingDTO[]> {
633+
return await this.get<RatingControllerMethods["getUsersRatingsForProject"]>(
634+
`/ratings/by-project/${project.id}`
635+
);
634636
}
635637

636638
/**

frontend/src/components/pages/criterion.tsx renamed to frontend/src/components/pages/rating-form.tsx

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,36 +13,38 @@ import {
1313
import { api } from "../../hooks/use-api";
1414
import { useLoginContext } from "../../contexts/login-context";
1515

16-
export const CriterionRating = ({
16+
/**
17+
* Component that allows users to submit and edit ratings for projects.
18+
* Only for one criterion, use multiple of this to cover all of them.
19+
*/
20+
export const RatingForm = ({
21+
rating,
1722
criterion,
1823
project,
1924
}) => {
2025
const loginState = useLoginContext();
2126
const { user } = loginState;
2227

23-
const [rating, setRating] = useState<string>("3");
28+
const [ratingValue, setRatingValue] = useState(rating.rating);
2429
const [isSubmitting, setIsSubmitting] = useState(false);
2530
const [error, setError] = useState<string | null>(null);
2631

2732
const handleSubmit = async () => {
2833
setIsSubmitting(true);
2934
setError(null);
3035

31-
console.log({ criterion, user, project })
32-
3336
await api.createRating({
3437
criterion: {
35-
Id: criterion.id
38+
id: criterion.id
3639
},
37-
rating: parseInt(rating),
40+
rating: parseInt(ratingValue),
3841
user: {
3942
id: user.id
4043
},
4144
project: {
4245
id: project.id
4346
},
4447
});
45-
onRatingSubmitted?.();
4648

4749
setIsSubmitting(false);
4850
};
@@ -70,8 +72,8 @@ export const CriterionRating = ({
7072
<FormControl component="fieldset">
7173
<RadioGroup
7274
row
73-
value={rating}
74-
onChange={(e) => setRating(e.target.value)}
75+
value={ratingValue}
76+
onChange={(e) => setRatingValue(e.target.value)}
7577
>
7678
{[1, 2, 3, 4, 5].map((value) => (
7779
<FormControlLabel

frontend/src/components/pages/read-only-project.tsx

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { useApi, api } from "../../hooks/use-api";
1010
import { useLoginContext } from "../../contexts/login-context";
1111
import { TeamDTO } from "../../api/types/dto";
1212
import { PageHeader } from "../base/page-header";
13-
import { CriterionRating } from "./criterion";
13+
import { RatingForm } from "./rating-form";
1414

1515
const HeaderContainer = styled(NonGrowingFlexContainer)`
1616
justify-content: space-between;
@@ -32,6 +32,7 @@ export const ReadOnlyProject = ({ project }) => {
3232
const [allowRating, setAllowRating] = React.useState(false);
3333
const [criteria, setCriteria] = React.useState([]);
3434
const [allUsers, setAllUsers] = React.useState([]);
35+
const [ratings, setRatings] = React.useState([]);
3536

3637
React.useEffect(() => {
3738
if (project) {
@@ -46,14 +47,20 @@ export const ReadOnlyProject = ({ project }) => {
4647
React.useEffect(
4748
() => {
4849
api.getAllUsers().then((allUsers) => {
49-
setAllUsers(allUsers)
50+
setAllUsers(allUsers);
5051
});
5152

5253
api.getAllCriteria().then((criteria) => {
53-
setCriteria(criteria)
54+
setCriteria(criteria);
5455
});
56+
57+
if (project) {
58+
api.getUsersRatingsForProject(project).then((ratings) => {
59+
setRatings(ratings);
60+
});
61+
}
5562
},
56-
[]
63+
[project]
5764
);
5865

5966
const {
@@ -105,7 +112,8 @@ export const ReadOnlyProject = ({ project }) => {
105112
<h2 style={{ "margin-top": "4rem" }}>Rate this Project</h2>
106113
Hover the criterion for more information.
107114
Rate criteria high, if you think the project did well in this regard.
108-
{criteria.map(criterion => <CriterionRating
115+
{criteria.map(criterion => <RatingForm
116+
rating={ratings.find(r => r.criterion.id == criterion.id)}
109117
criterion={criterion}
110118
project={project}
111119
/>)}

0 commit comments

Comments
 (0)