Skip to content

Commit 0fe9532

Browse files
authored
Rating Projects (#111)
1 parent 984b6e7 commit 0fe9532

107 files changed

Lines changed: 5921 additions & 3410 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
package-lock.json
12
node_modules/
23
dist/
34
coverage/

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77

88
Yet another hackathon registration system.
99

10+
[Docker Development quickstart.md](quickstart.md)
11+
1012
## Motivation
1113

1214
Like many other hackathons, we previously used [Quill](https://github.com/techx/quill) for our application process, which worked really well for us in the past. Especially Quill's process was a blessing: an application consists of two steps, the profile creation and, once an attendee was admitted to the event, the spot confirmation. We attended different events that used different processes and found this to be easy for both the attendees and organizers.
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import {
2+
Authorized,
3+
Delete,
4+
Get,
5+
JsonController,
6+
Param,
7+
Put,
8+
Post,
9+
Body,
10+
} from "routing-controllers";
11+
import { Inject } from "typedi";
12+
import { UserRole } from "../entities/user-role";
13+
import {
14+
CriterionServiceToken,
15+
ICriterionService,
16+
} from "../services/criterion-service";
17+
import {
18+
CriterionDTO,
19+
SuccessResponseDTO,
20+
convertBetweenEntityAndDTO,
21+
} from "./dto";
22+
import { Criterion } from "../entities/criterion";
23+
24+
@JsonController("/criteria")
25+
export class CriterionController {
26+
public constructor(
27+
@Inject(CriterionServiceToken)
28+
private readonly _criterion: ICriterionService,
29+
) {}
30+
31+
/**
32+
* Get all criteria.
33+
*/
34+
@Get("/")
35+
@Authorized(UserRole.User)
36+
public async getAllCriteria(): Promise<CriterionDTO[]> {
37+
const criteria = await this._criterion.getAllCriteria();
38+
return criteria.map((c) => convertBetweenEntityAndDTO(c, CriterionDTO));
39+
}
40+
41+
/**
42+
* Create a criterion.
43+
*/
44+
@Post("/")
45+
@Authorized(UserRole.Root)
46+
public async createCriterion(
47+
@Body() { data: criterionDTO }: { data: CriterionDTO },
48+
): Promise<CriterionDTO> {
49+
const criterion = convertBetweenEntityAndDTO(criterionDTO, Criterion);
50+
const createdCriterion = await this._criterion.createCriterion(criterion);
51+
return convertBetweenEntityAndDTO(createdCriterion, CriterionDTO);
52+
}
53+
54+
/**
55+
* Update criteria.
56+
*/
57+
@Put("/:id")
58+
@Authorized(UserRole.Root)
59+
public async updateCriterion(
60+
@Param("id") criterionId: number,
61+
@Body() { data: criterionDTO }: { data: CriterionDTO },
62+
): Promise<CriterionDTO> {
63+
const criterion = convertBetweenEntityAndDTO(
64+
{ ...criterionDTO, id: criterionId },
65+
Criterion,
66+
);
67+
const updateCriterion = await this._criterion.updateCriterion(criterion);
68+
return convertBetweenEntityAndDTO(updateCriterion, CriterionDTO);
69+
}
70+
71+
/**
72+
* Delete criteria.
73+
*/
74+
@Delete("/:id")
75+
@Authorized(UserRole.Root)
76+
public async deleteCriterion(
77+
@Param("id") criterionId: number,
78+
): Promise<SuccessResponseDTO> {
79+
await this._criterion.deleteCriterionByID(criterionId);
80+
const response = new SuccessResponseDTO();
81+
response.success = true;
82+
return response;
83+
}
84+
}

backend/src/controllers/dto.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@ import {
1010
IsOptional,
1111
IsString,
1212
IsUrl,
13+
Max,
1314
MaxLength,
15+
Min,
1416
MinLength,
1517
ValidateNested,
1618
} from "class-validator";
@@ -89,6 +91,9 @@ export class ApplicationSettingsDTO implements DTO<ApplicationSettings> {
8991
@IsNumber()
9092
@Expose()
9193
public hoursToConfirm!: number;
94+
@IsBoolean()
95+
@Expose()
96+
public allowRatingProjects!: boolean;
9297
}
9398

9499
export class FrontendSettingsDTO implements DTO<FrontendSettings> {
@@ -561,3 +566,84 @@ export class TeamUpdateDTO {
561566
@Expose()
562567
public description!: string;
563568
}
569+
570+
export class CriterionDTO {
571+
@Expose()
572+
public readonly id!: number;
573+
@Expose()
574+
public title!: string;
575+
@Expose()
576+
public description!: string;
577+
}
578+
579+
export class ProjectDTO {
580+
@Expose()
581+
public readonly id!: number;
582+
@Expose()
583+
@Type(() => TeamDTO)
584+
@ValidateNested()
585+
public team!: TeamDTO;
586+
@Expose()
587+
public title!: string;
588+
@Expose()
589+
public description!: string;
590+
@Expose()
591+
public allowRating!: boolean;
592+
@Expose()
593+
public image!: string;
594+
}
595+
596+
export class ProjectUpdateDTO {
597+
@Expose()
598+
public readonly id!: number;
599+
@Expose()
600+
public title!: string;
601+
@Expose()
602+
public description!: string;
603+
@Expose()
604+
public allowRating!: boolean;
605+
@Expose()
606+
public image!: string;
607+
}
608+
609+
export class RatingDTO {
610+
@Expose()
611+
public readonly id!: number;
612+
@Expose()
613+
@Type(() => UserDTO)
614+
@ValidateNested()
615+
public user!: UserDTO;
616+
@Expose()
617+
@Type(() => ProjectDTO)
618+
@ValidateNested()
619+
public project!: ProjectDTO;
620+
@Expose()
621+
@Type(() => CriterionDTO)
622+
@ValidateNested()
623+
public criterion!: CriterionDTO;
624+
@Expose()
625+
@IsInt()
626+
@Min(1)
627+
@Max(5)
628+
public rating!: number;
629+
}
630+
631+
class CriterionAvgDTO {
632+
@Expose()
633+
@Type(() => CriterionDTO)
634+
public criterion!: CriterionDTO;
635+
@Expose()
636+
public average!: number;
637+
}
638+
639+
// Do not send all ratings to the client,
640+
// because peoples opinion on the projects should be anonymous
641+
export class ProjectRatingResultDTO {
642+
@Expose()
643+
@Type(() => ProjectDTO)
644+
public project!: ProjectDTO;
645+
@IsArray()
646+
@Type(() => CriterionAvgDTO)
647+
@Expose()
648+
public averagesPerCriterion!: CriterionAvgDTO[];
649+
}
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import {
2+
Authorized,
3+
Get,
4+
JsonController,
5+
NotFoundError,
6+
Put,
7+
Param,
8+
Body,
9+
CurrentUser,
10+
} from "routing-controllers";
11+
import { Inject } from "typedi";
12+
import { UserRole } from "../entities/user-role";
13+
import {
14+
IProjectService,
15+
ProjectServiceToken,
16+
} from "../services/project-service";
17+
import {
18+
ProjectDTO,
19+
ProjectUpdateDTO,
20+
convertBetweenEntityAndDTO,
21+
} from "./dto";
22+
import { Project } from "../entities/project";
23+
import { User } from "../entities/user";
24+
25+
@JsonController("/projects")
26+
export class ProjectsController {
27+
public constructor(
28+
@Inject(ProjectServiceToken) private readonly _projects: IProjectService,
29+
) {}
30+
31+
/**
32+
* Get all projects.
33+
*/
34+
@Get("/")
35+
@Authorized(UserRole.User)
36+
public async getAllProjects(
37+
@CurrentUser() user: User,
38+
): Promise<ProjectDTO[]> {
39+
const projects = await this._projects.getAllProjects(user);
40+
return projects.map((p) => convertBetweenEntityAndDTO(p, ProjectDTO));
41+
}
42+
43+
/**
44+
* Get project by id.
45+
* @param id The id of the project
46+
*/
47+
@Get("/:id")
48+
@Authorized(UserRole.User)
49+
public async getProjectByID(@Param("id") id: number): Promise<ProjectDTO> {
50+
const project = await this._projects.getProjectByID(id);
51+
52+
if (project == null) {
53+
throw new NotFoundError(`no project with id ${id}`);
54+
}
55+
56+
return convertBetweenEntityAndDTO(project, ProjectDTO);
57+
}
58+
59+
/**
60+
* Update a project (mvp: create one project per team)
61+
*/
62+
@Put("/:id")
63+
@Authorized(UserRole.User)
64+
public async updateProject(
65+
@Param("id") projectId: number,
66+
@Body() { data: projectDTO }: { data: ProjectUpdateDTO },
67+
@CurrentUser() user: User,
68+
): Promise<ProjectDTO> {
69+
const existing = await this._projects.getProjectByID(projectId);
70+
71+
if (existing == null) {
72+
throw new NotFoundError();
73+
}
74+
75+
const project = convertBetweenEntityAndDTO(
76+
{
77+
...projectDTO,
78+
id: projectId,
79+
},
80+
Project,
81+
);
82+
83+
const updatedProject = await this._projects.updateProject(project, user);
84+
return convertBetweenEntityAndDTO(updatedProject, ProjectDTO);
85+
}
86+
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import {
2+
Authorized,
3+
JsonController,
4+
CurrentUser,
5+
Get,
6+
Post,
7+
Body,
8+
Param,
9+
} from "routing-controllers";
10+
import { Inject } from "typedi";
11+
import { UserRole } from "../entities/user-role";
12+
import { RatingServiceToken, IRatingService } from "../services/rating-service";
13+
import {
14+
RatingDTO,
15+
ProjectRatingResultDTO,
16+
convertBetweenEntityAndDTO,
17+
} from "./dto";
18+
import { User } from "../entities/user";
19+
import { Rating } from "../entities/rating";
20+
21+
@JsonController("/ratings")
22+
export class RatingController {
23+
public constructor(
24+
@Inject(RatingServiceToken) private readonly _ratings: IRatingService,
25+
) {}
26+
27+
/**
28+
* Get aggregated rating results grouped by project and criteria.
29+
*/
30+
@Get("/by-project/:id")
31+
@Authorized(UserRole.User)
32+
public async getUsersRatingsForProject(
33+
@Param("id") projectId: number,
34+
@CurrentUser() user: User,
35+
): Promise<RatingDTO[]> {
36+
const results = await this._ratings.getUsersRatingsForProject(
37+
projectId,
38+
user,
39+
);
40+
return results.map((r) => convertBetweenEntityAndDTO(r, RatingDTO));
41+
}
42+
43+
/**
44+
* Get aggregated rating results grouped by project and criteria.
45+
*/
46+
@Get("/results")
47+
@Authorized(UserRole.Root)
48+
public async getRatingResults(): Promise<ProjectRatingResultDTO[]> {
49+
const results = await this._ratings.getRatingResults();
50+
return results.map((r) =>
51+
convertBetweenEntityAndDTO(r, ProjectRatingResultDTO),
52+
);
53+
}
54+
55+
/**
56+
* Rate a project
57+
*/
58+
@Post("/rate")
59+
@Authorized(UserRole.User)
60+
public async rate(
61+
@Body() { data: ratingDTO }: { data: RatingDTO },
62+
@CurrentUser() user: User,
63+
): Promise<RatingDTO> {
64+
const rating = convertBetweenEntityAndDTO(ratingDTO, Rating);
65+
66+
// Ensure ratings cannot be cast for other users,
67+
// write the requesting user into it.
68+
rating.user = user;
69+
70+
const createdRating = await this._ratings.upsertRating(rating, user);
71+
return convertBetweenEntityAndDTO(createdRating, RatingDTO);
72+
}
73+
}

backend/src/entities/application-settings.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ import {
88
} from "typeorm";
99
import { FormSettings } from "./form-settings";
1010

11+
// TODO all other settings are part of the settings table, whereas ApplicationSettings
12+
// is a separate table. Move into settings table just like EmailSettings.
13+
1114
@Entity()
1215
export class ApplicationSettings {
1316
@PrimaryGeneratedColumn()
@@ -26,4 +29,6 @@ export class ApplicationSettings {
2629
public allowProfileFormUntil!: Date;
2730
@Column()
2831
public hoursToConfirm!: number;
32+
@Column({ default: false })
33+
public allowRatingProjects!: boolean;
2934
}

backend/src/entities/criterion.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { Column, Entity, PrimaryGeneratedColumn } from "typeorm";
2+
import { Longtext } from "./longtext";
3+
4+
@Entity()
5+
export class Criterion {
6+
@PrimaryGeneratedColumn()
7+
public readonly id!: number;
8+
@Column({ length: 1024 })
9+
public title!: string;
10+
@Longtext()
11+
public description!: string;
12+
}

0 commit comments

Comments
 (0)