Skip to content

Commit 2c58dc0

Browse files
authored
Application answering page (#640)
* initial nextjs setup * rename "Resume Application" -> "Continue Application" * update frontend-nextjs/.gitignore * Update bun.lock * setup server-side auth functions * basic sidebar * setup i18n * add common terms to dictionaries * remove unused backend imports * run rust build workflow on pulls to `CHAOS-571-integrate-be-fe` * react query auth setup * fetch user organisations for sidebar * handle organisation change on dashboard * setup basic dashboard campaigns page * add data table to display campaigns * dashboard campaign details page * application review setup * campaign details rearrange * application review scaffold * move application models * application review component basics * get, set and update application rating * fix setting & updating application rating * add `chaos.png` * add buttons for publish & edit campaign questions * Email templates UI (#608) * initial devcontainer setup * basic email template get, update and delete * template creation ui * remove error printing * convert all plain text responses to `AppMessage` * fix email template card margins * wrap `params` prop type with `Promise<>` * actions workflows for nextjs * add @radix-ui/react-alert-dialog * add @radix-ui/react-tabs * only run workflows when relevant paths modified * Update .gitignore * Update .gitignore * chopped drag and drop * drag and drop less chopped * less chopped drag and drop * role tabs * textbox * refactor roleselector and switch to globals * save role selection and fix frontend 404 * cleanups * fix ranking(bit of vibe) * fix role ordering * run migration * fix: make applicationanswer keep track of qanda states * feat: refactor to prefetch answers * feat: display questions inside card * card submit * fix: asterisk on required * fix: get card to deactivate * fix: shared answers now update in card * feat: guard submitted applications from accessing the apply screen * fix: do a pass to clean up some code * feat: use dict across component * fix: dropwdown clear works without bricking the backend on fetch, now need to refactor shortanswer and multiselect to simply delete answer * style: fix no answer message * feat: refactor empty question types to use delete answer * fix: fix a few breaking changes caused by swapping over and in general * fix: fix question type logic which broke when swapping over to the delete logic * fix: bug fixes * fix: role reordering behaviour * fix: fix inconsistent redirect behaviour * chore: regenerate bun thing * merge: more merges + bug fixes * feat: link answerpage component to campaign info component * fix: wait for queries to be ready before repopulating qamap, (hopefully) avoiding races between updating shared questions and reviewcard * chore: remove node_modules * fix: cleanup file * fix: stringify answers in application review page
1 parent 12b702f commit 2c58dc0

File tree

41 files changed

+7085
-163
lines changed

Some content is hidden

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

41 files changed

+7085
-163
lines changed
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
-- Add migration script here
2+
ALTER TABLE campaigns
3+
ADD COLUMN max_roles_per_application integer;

backend/package-lock.json

Lines changed: 48 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

backend/package.json

Whitespace-only changes.

backend/server/src/handler/application.rs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,29 @@ impl ApplicationHandler {
8787
) -> Result<impl IntoResponse, ChaosError> {
8888
let application = Application::get(application_id, admin.user_id, &mut transaction.tx).await?;
8989
transaction.tx.commit().await?;
90+
Ok((StatusCode::OK, Json(application)))
91+
}
92+
93+
/// Retrieves the details of a specific application regardless of submission status.
94+
///
95+
/// This handler allows regular applicants to view application details in the answer screen.
96+
///
97+
/// # Arguments
98+
///
99+
/// * `application_id` - The ID of the application to retrieve
100+
/// * `_admin` - The authenticated user (must be an application admin)
101+
/// * `transaction` - Database transaction
102+
///
103+
/// # Returns
104+
///
105+
/// * `Result<impl IntoResponse, ChaosError>` - Application details or error
106+
pub async fn get_in_progress(
107+
Path(application_id): Path<i64>,
108+
user: AuthUser,
109+
mut transaction: DBTransaction<'_>,
110+
) -> Result<impl IntoResponse, ChaosError> {
111+
let application = Application::get_in_progress(application_id, user.user_id, &mut transaction.tx).await?;
112+
transaction.tx.commit().await?;
90113
Ok(Json(application))
91114
}
92115

backend/server/src/models/answer.rs

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,24 @@ pub struct Answer {
4848
updated_at: DateTime<Utc>,
4949
}
5050

51+
/// A view type which collects an answer in the system along with it's
52+
/// associated role.
53+
#[derive(Deserialize, Serialize)]
54+
pub struct AnswerWithRole {
55+
#[serde(serialize_with = "crate::models::serde_string::serialize")]
56+
id: i64,
57+
/// ID of the question this answer is for
58+
#[serde(serialize_with = "crate::models::serde_string::serialize")]
59+
question_id: i64,
60+
61+
/// The actual answer data, flattened in serialization
62+
#[serde(flatten)]
63+
data: AnswerData,
64+
65+
// role ID
66+
#[serde(serialize_with = "crate::models::serde_string::serialize")]
67+
role_id: i64
68+
}
5169
/// Data structure for creating a new answer.
5270
///
5371
/// Contains the question ID and the answer data.
@@ -118,6 +136,16 @@ impl Answer {
118136
) -> Result<i64, ChaosError> {
119137
data.validate()?;
120138

139+
sqlx::query!(
140+
"
141+
DELETE FROM answers
142+
WHERE application_id = $1 AND question_id = $2
143+
",
144+
application_id,
145+
question_id
146+
)
147+
.execute(transaction.deref_mut())
148+
.await?;
121149
let id = snowflake_generator.real_time_generate();
122150

123151
sqlx::query!(
@@ -378,7 +406,9 @@ impl Answer {
378406
data: AnswerData,
379407
transaction: &mut Transaction<'_, Postgres>,
380408
) -> Result<(), ChaosError> {
381-
data.validate()?;
409+
if !data.is_empty() {
410+
data.validate()?;
411+
}
382412

383413
let answer = sqlx::query_as!(
384414
AnswerTypeApplicationId,
@@ -396,7 +426,9 @@ impl Answer {
396426
let old_data = AnswerData::from_question_type(&answer.question_type);
397427
old_data.delete_from_db(id, transaction).await?;
398428

399-
data.insert_into_db(id, transaction).await?;
429+
if !data.is_empty() {
430+
data.insert_into_db(id, transaction).await?;
431+
}
400432

401433
sqlx::query!(
402434
"UPDATE applications SET updated_at = $1 WHERE id = $2",
@@ -520,6 +552,15 @@ impl AnswerData {
520552
}
521553
}
522554

555+
pub fn is_empty(&self) -> bool {
556+
match self {
557+
AnswerData::ShortAnswer(text) => text.is_empty(),
558+
AnswerData::MultiSelect(options) | AnswerData::Ranking(options) => options.is_empty(),
559+
AnswerData::MultiChoice(option_id) => false,
560+
AnswerData::DropDown(option_id) => *option_id == 0
561+
}
562+
}
563+
523564
/// Validates the answer data.
524565
///
525566
/// # Returns

backend/server/src/models/app.rs

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -277,20 +277,6 @@ pub async fn app() -> Result<Router, ChaosError> {
277277
.delete(RatingHandler::delete)
278278
.put(RatingHandler::update),
279279
)
280-
.route(
281-
"/api/v1/application/:application_id/rating",
282-
get(ApplicationHandler::get_rating_by_current_user)
283-
.post(ApplicationHandler::create_rating)
284-
.put(ApplicationHandler::update_rating),
285-
)
286-
.route(
287-
"/api/v1/application/:application_id/ratings",
288-
get(ApplicationHandler::get_ratings),
289-
)
290-
.route(
291-
"/api/v1/campaign/:campaign_id/avg_ratings",
292-
get(ApplicationHandler::get_application_ratings_summary),
293-
)
294280
.route(
295281
"/api/v1/campaign/:campaign_id/role",
296282
post(CampaignHandler::create_role),
@@ -381,6 +367,20 @@ pub async fn app() -> Result<Router, ChaosError> {
381367
"/api/v1/application/:application_id",
382368
get(ApplicationHandler::get),
383369
)
370+
.route(
371+
"/api/v1/application/:application_id/inprogress",
372+
get(ApplicationHandler::get_in_progress),
373+
)
374+
.route(
375+
"/api/v1/application/:application_id/rating",
376+
get(ApplicationHandler::get_rating_by_current_user)
377+
.post(ApplicationHandler::create_rating)
378+
.put(ApplicationHandler::update_rating),
379+
)
380+
.route(
381+
"/api/v1/application/:application_id/ratings",
382+
get(ApplicationHandler::get_ratings),
383+
)
384384
.route(
385385
"/api/v1/application/:application_id/status",
386386
patch(ApplicationHandler::set_status),

backend/server/src/models/application.rs

Lines changed: 95 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,19 @@ pub struct ApplicationRole {
7171
pub preference: i32,
7272
}
7373

74+
/// Data structure for creating a new application.
75+
///
76+
/// Contains a list of role ids and preferences
77+
#[derive(Deserialize)]
78+
pub struct UpdateRoleEntry {
79+
/// ID of the campaign role being applied for
80+
#[serde(serialize_with = "crate::models::serde_string::serialize")]
81+
#[serde(deserialize_with = "crate::models::serde_string::deserialize")]
82+
pub campaign_role_id: i64,
83+
/// User's preference ranking for this role (lower number = higher preference)
84+
pub preference: i32,
85+
}
86+
7487
/// Data structure for creating a new application.
7588
///
7689
/// Contains the list of roles the user is applying for, with their preferences.
@@ -159,7 +172,8 @@ pub struct ApplicationAppliedRoleDetails {
159172
#[derive(Deserialize)]
160173
pub struct ApplicationRoleUpdate {
161174
/// Updated list of role preferences
162-
pub roles: Vec<ApplicationRole>,
175+
//pub roles: Vec<ApplicationRole>,
176+
pub roles: Vec<UpdateRoleEntry>,
163177
}
164178

165179
/// Possible statuses for an application.
@@ -402,6 +416,84 @@ impl Application {
402416
})
403417
}
404418

419+
/// Retrieves an application by its ID regardless of submission status.
420+
/// This handler allows regular applicants to view application details in the answer screen.
421+
/// # Arguments
422+
///
423+
/// * `id` - ID of the application to retrieve
424+
/// * `transaction` - Database transaction to use
425+
///
426+
/// # Returns
427+
///
428+
/// * `Result<ApplicationDetails, ChaosError>` - Application details or error
429+
pub async fn get_in_progress(
430+
id: i64,
431+
current_user: i64,
432+
transaction: &mut Transaction<'_, Postgres>,
433+
) -> Result<ApplicationDetails, ChaosError> {
434+
let application_data = match sqlx::query_as!(
435+
ApplicationData,
436+
"
437+
SELECT a.id AS id, campaign_id, user_id, status AS \"status: ApplicationStatus\",
438+
private_status AS \"private_status: ApplicationStatus\", u.email AS user_email,
439+
u.zid AS user_zid, u.name AS user_name, u.gender AS user_gender,
440+
u.pronouns AS user_pronouns, u.degree_name AS user_degree_name,
441+
u.degree_starting_year AS user_degree_starting_year,
442+
(ar.id IS NOT NULL) AS \"current_user_rated!: bool\"
443+
FROM applications a
444+
JOIN users u ON u.id = a.user_id
445+
JOIN campaigns c ON c.id = a.campaign_id
446+
LEFT JOIN application_ratings ar ON ar.application_id = a.id AND ar.application_id = $2
447+
WHERE a.id = $1 AND a.submitted = false
448+
",
449+
id,
450+
current_user
451+
)
452+
.fetch_one(transaction.deref_mut())
453+
.await {
454+
Ok(application) => application,
455+
Err(sqlx::Error::RowNotFound) => {
456+
return Err(ChaosError::BadRequest);
457+
},
458+
Err(e) => return Err(e.into()),
459+
};
460+
461+
let applied_roles = sqlx::query_as!(
462+
ApplicationAppliedRoleDetails,
463+
"
464+
SELECT application_roles.campaign_role_id,
465+
application_roles.preference, campaign_roles.name AS role_name
466+
FROM application_roles
467+
JOIN campaign_roles
468+
ON application_roles.campaign_role_id = campaign_roles.id
469+
WHERE application_id = $1
470+
ORDER BY campaign_roles.id
471+
",
472+
id
473+
)
474+
.fetch_all(transaction.deref_mut())
475+
.await?;
476+
477+
Ok(ApplicationDetails {
478+
id: application_data.id,
479+
campaign_id: application_data.campaign_id,
480+
status: application_data.status,
481+
private_status: application_data.private_status,
482+
applied_roles,
483+
current_user_rated: application_data.current_user_rated,
484+
user: UserDetails {
485+
id: application_data.user_id,
486+
email: application_data.user_email,
487+
zid: application_data.user_zid,
488+
name: application_data.user_name,
489+
pronouns: application_data.user_pronouns,
490+
gender: application_data.user_gender,
491+
degree_name: application_data.user_degree_name,
492+
degree_starting_year: application_data.user_degree_starting_year,
493+
},
494+
})
495+
}
496+
405497
/// Retrieves all applications for a specific role.
406498
///
407499
/// # Arguments
@@ -786,7 +878,7 @@ impl Application {
786878
SELECT id, application_id, campaign_role_id, preference
787879
FROM application_roles
788880
WHERE application_id = $1
789-
ORDER BY campaign_role_id
881+
ORDER BY preference
790882
",
791883
id
792884
)
@@ -809,7 +901,7 @@ impl Application {
809901
/// * `Result<(), ChaosError>` - Success or error
810902
pub async fn update_roles(
811903
id: i64,
812-
roles: Vec<ApplicationRole>,
904+
roles: Vec<UpdateRoleEntry>,
813905
transaction: &mut Transaction<'_, Postgres>,
814906
) -> Result<(), ChaosError> {
815907
sqlx::query!(

backend/server/src/models/campaign.rs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,8 @@ pub struct Campaign {
6464
pub application_requirements: Option<String>,
6565
/// Whether the campaign is published
6666
pub published: bool,
67+
/// Max amount of roles an applicant can apply for
68+
pub max_roles_per_application: Option<i32>
6769
}
6870

6971
/// Detailed view of a campaign.
@@ -109,6 +111,8 @@ pub struct CampaignDetails {
109111
pub application_requirements: Option<String>,
110112
/// Whether the campaign is published
111113
pub published: bool,
114+
/// Max amount of roles an applicant can apply for
115+
pub max_roles_per_application: Option<i32>
112116
}
113117

114118
/// Simplified view of a campaign for organization listings.
@@ -322,7 +326,7 @@ impl Campaign {
322326
o.slug AS organisation_slug, o.name as organisation_name,
323327
o.contact_email, o.website_url, c.cover_image,
324328
c.description, c.starts_at, c.ends_at, c.published, c.interview_period_starts_at,
325-
c.interview_period_ends_at, c.interview_format, c.outcomes_released_at,
329+
c.interview_period_ends_at, c.interview_format, c.outcomes_released_at, c.max_roles_per_application,
326330
c.application_requirements
327331
FROM campaigns c
328332
JOIN organisations o on c.organisation_id = o.id
@@ -399,7 +403,7 @@ impl Campaign {
399403
SELECT c.id, c.slug AS campaign_slug, c.name, c.organisation_id,
400404
o.slug AS organisation_slug, o.name as organisation_name,
401405
o.contact_email, o.website_url, c.cover_image,
402-
c.description, c.starts_at, c.ends_at, c.published,
406+
c.description, c.starts_at, c.ends_at, c.published, c.max_roles_per_application,
403407
c.interview_period_starts_at, c.interview_period_ends_at, c.interview_format,
404408
c.outcomes_released_at, c.application_requirements
405409
FROM campaigns c

0 commit comments

Comments
 (0)