diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 000000000..fd498c337 --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,8 @@ +FROM mcr.microsoft.com/devcontainers/base:ubuntu + +# Setup packages +RUN apt update && export DEBIAN_FRONTEND=noninteractive \ + && apt -y install --no-install-recommends libssl-dev pkgconf build-essential postgresql-client + +# Note: Rust, Node.js, and Bun are installed via devcontainer.json features. +# sqlx-cli will be installed in post-create.sh to ensure it uses the correct cargo environment. diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 000000000..2e8a64de0 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,27 @@ +{ + "name": "Chaos Dev Container", + "dockerComposeFile": "docker-compose.yml", + "service": "app", + "workspaceFolder": "/workspaces/chaos", + "features": { + "ghcr.io/devcontainers/features/node:1": { + "version": "lts" + }, + "ghcr.io/devcontainers/features/rust:1": { + "version": "latest" + }, + "ghcr.io/michidk/devcontainers-features/bun:1": { + "version": "latest" + } + }, + "customizations": { + "vscode": { + "extensions": [ + "rust-lang.rust-analyzer", + "DBCode.dbcode" + ] + } + }, + "postCreateCommand": "bash .devcontainer/post-create.sh", + "remoteUser": "vscode" +} diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml new file mode 100644 index 000000000..c544cdf77 --- /dev/null +++ b/.devcontainer/docker-compose.yml @@ -0,0 +1,29 @@ +services: + app: + build: + context: . + dockerfile: Dockerfile + volumes: + - ..:/workspaces/chaos:cached + # Overrides default command so things don't shut down after the process ends. + command: sleep infinity + # Use the same network as the db service to access it via localhost + network_mode: service:db + + db: + image: postgres:17 + restart: unless-stopped + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: password + POSTGRES_DB: chaos + volumes: + - chaos-postgres-data:/var/lib/postgresql/data + # Forward ports: 5432 (db), 3000 (frontend), 8080 (backend) + ports: + - "5433:5432" + - "3000:3000" + - "8080:8080" + +volumes: + chaos-postgres-data: diff --git a/.devcontainer/post-create.sh b/.devcontainer/post-create.sh new file mode 100644 index 000000000..5737fa5ea --- /dev/null +++ b/.devcontainer/post-create.sh @@ -0,0 +1,46 @@ +#!/bin/bash +set -e + +echo "Starting post-create setup..." + +# Create backend/.env +echo "Creating backend/.env..." +cat << 'EOF' > backend/.env +DATABASE_URL="postgres://postgres:password@localhost:5432/chaos" +JWT_SECRET="test_secret" +GOOGLE_CLIENT_ID="test" +GOOGLE_CLIENT_SECRET="test" +GOOGLE_REDIRECT_URI="http://localhost:3000/auth/callback" +S3_BUCKET_NAME="chaos-storage" +S3_ACCESS_KEY="test_access_key" +S3_SECRET_KEY="test_secret_key" +S3_ENDPOINT="https://chaos-storage.s3.ap-southeast-1.amazonaws.com" +S3_REGION_NAME="ap-southeast-1" +DEV_ENV="dev" +SMTP_USERNAME="test_username" +SMTP_PASSWORD="test_password" +SMTP_HOST="smtp.example.com" +EOF + +# Install sqlx-cli if not present +if ! command -V sqlx &> /dev/null; then + echo "Installing sqlx-cli..." + cargo install sqlx-cli --no-default-features --features native-tls,postgres +fi + +# Wait for Postgres +echo "Waiting for Postgres..." +until PGPASSWORD=password psql -h "db" -U "postgres" -d "chaos" -c '\q' 2>/dev/null; do + echo "Waiting for postgres at db:5432..." + sleep 2 +done + +# Setup DB +echo "Setting up database..." +cd backend + +# Create database if not exists +sqlx database create || true +sqlx migrate run + +echo "Setup complete!" diff --git a/.github/workflows/frontend-nextjs.yml b/.github/workflows/frontend-nextjs.yml new file mode 100644 index 000000000..e4ee44f0c --- /dev/null +++ b/.github/workflows/frontend-nextjs.yml @@ -0,0 +1,38 @@ +name: Frontend (Next.js) + +on: + pull_request: + branches: [main, "renovate/*", "CHAOS-224-KHAOS-rewrite", "CHAOS-571-integrate-be-fe", "CHAOS-598-nextjs"] + paths: + - "frontend-nextjs/**" + - ".github/workflows/frontend-nextjs.yml" + push: + branches: ["renovate/*"] + paths: + - "frontend-nextjs/**" + - ".github/workflows/frontend-nextjs.yml" + +jobs: + build: + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./frontend-nextjs + + steps: + - uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v1 + with: + bun-version: latest + + - name: Install dependencies + run: bun install + + - name: Build + run: bun run build + env: + NEXT_PUBLIC_APP_URL: http://localhost:3000 + NEXT_OAUTH_CALLBACK_URL: http://localhost:8080/auth/google + NEXT_API_BASE_URL: https://localhost:8080 diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index aa319aa71..78e45c916 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -2,9 +2,15 @@ name: Rust on: pull_request: - branches: [main, "renovate/*", "CHAOS-224-KHAOS-rewrite", "CHAOS-571-integrate-be-fe"] + branches: [main, "renovate/*", "CHAOS-224-KHAOS-rewrite", "CHAOS-571-integrate-be-fe", "CHAOS-598-nextjs"] + paths: + - "backend/**" + - ".github/workflows/rust.yml" push: branches: ["renovate/*"] + paths: + - "backend/**" + - ".github/workflows/rust.yml" env: CARGO_TERM_COLOR: always diff --git a/backend/database-seeding/src/seeder.rs b/backend/database-seeding/src/seeder.rs index 63758fb76..e9e1e458c 100644 --- a/backend/database-seeding/src/seeder.rs +++ b/backend/database-seeding/src/seeder.rs @@ -325,14 +325,16 @@ pub async fn seed_database(mut seeder: Seeder) { .await.expect("Failed seeding Rating 3"); let template = - "Hello {{name}}, + " +Hello {{name}}, - Congratulations! You have been selected for the role of {{role}} at {{organisation_name}} for our {{campaign_name}}. +Congratulations! You have been selected for the role of {{role}} at {{organisation_name}} for our {{campaign_name}}. - Please confirm your acceptance by {{expiry_date}}. +Please confirm your acceptance by {{expiry_date}}. - Best regards, - The {{organisation_name}} Team".to_string(); +Best regards, +The {{organisation_name}} Team + ".to_string(); let email_template_id = Organisation::create_email_template( diff --git a/backend/server/Cargo.toml b/backend/server/Cargo.toml index a428e3a67..17a3c998f 100644 --- a/backend/server/Cargo.toml +++ b/backend/server/Cargo.toml @@ -22,6 +22,7 @@ chrono = { version = "0.4", features = ["serde"] } oauth2 = "4.4" log = "0.4" uuid = { version = "1.5", features = ["serde", "v4"] } +nanoid = "0.4.0" rust-s3 = "0.34.0" rs-snowflake = "0.6" jsonwebtoken = "9.1" diff --git a/backend/server/src/constants.rs b/backend/server/src/constants.rs new file mode 100644 index 000000000..507901ac3 --- /dev/null +++ b/backend/server/src/constants.rs @@ -0,0 +1,3 @@ +pub const NANOID_ALPHABET: [char; 16] = [ +'1', '2', '3', '4', '5', '6', '7', '8', '9', '0', 'a', 'b', 'c', 'd', 'e', 'f' +]; diff --git a/backend/server/src/handler/answer.rs b/backend/server/src/handler/answer.rs index 70a368a4a..6f097dbcd 100644 --- a/backend/server/src/handler/answer.rs +++ b/backend/server/src/handler/answer.rs @@ -6,7 +6,7 @@ //! - Managing role-specific answers use crate::models::answer::{Answer, NewAnswer}; -use crate::models::app::AppState; +use crate::models::app::{AppMessage, AppState}; use crate::models::auth::{AnswerOwner, ApplicationOwner, ApplicationOwnerOrReviewer}; use crate::models::error::ChaosError; use crate::models::transaction::DBTransaction; @@ -141,7 +141,7 @@ impl AnswerHandler { transaction.tx.commit().await?; - Ok((StatusCode::OK, "Successfully updated answer")) + Ok(AppMessage::OkMessage("Successfully updated answer")) } /// Deletes an answer. @@ -169,6 +169,6 @@ impl AnswerHandler { transaction.tx.commit().await?; - Ok((StatusCode::OK, "Successfully deleted answer")) + Ok(AppMessage::OkMessage("Successfully deleted answer")) } } diff --git a/backend/server/src/handler/application.rs b/backend/server/src/handler/application.rs index 84f3c1982..a8ba17cf1 100644 --- a/backend/server/src/handler/application.rs +++ b/backend/server/src/handler/application.rs @@ -112,7 +112,7 @@ impl ApplicationHandler { ) -> Result { Application::set_status(application_id, data, &mut transaction.tx).await?; transaction.tx.commit().await?; - Ok((StatusCode::OK, "Status successfully updated")) + Ok(AppMessage::OkMessage("Status successfully updated")) } /// Updates the private status of an application. @@ -137,7 +137,7 @@ impl ApplicationHandler { ) -> Result { Application::set_private_status(application_id, data, &mut transaction.tx).await?; transaction.tx.commit().await?; - Ok((StatusCode::OK, "Private Status successfully updated")) + Ok(AppMessage::OkMessage("Private Status successfully updated")) } /// Retrieves all applications for the current user. @@ -209,7 +209,7 @@ impl ApplicationHandler { ) -> Result { Application::update_roles(application_id, data.roles, &mut transaction.tx).await?; transaction.tx.commit().await?; - Ok((StatusCode::OK, "Successfully updated application roles")) + Ok(AppMessage::OkMessage("Successfully updated application roles")) } /// Submits an application for review. @@ -235,7 +235,7 @@ impl ApplicationHandler { ) -> Result { Application::submit(application_id, &mut transaction.tx).await?; transaction.tx.commit().await?; - Ok((StatusCode::OK, "Successfully submitted application")) + Ok(AppMessage::OkMessage("Successfully submitted application")) } /// Retrieves the rating for an application given by the current user. diff --git a/backend/server/src/handler/campaign.rs b/backend/server/src/handler/campaign.rs index 501877181..943ef7bd7 100644 --- a/backend/server/src/handler/campaign.rs +++ b/backend/server/src/handler/campaign.rs @@ -8,7 +8,7 @@ //! - Banner image handling use crate::models; -use crate::models::app::AppState; +use crate::models::app::{AppMessage, AppState}; use crate::models::application::Application; use crate::models::application::NewApplication; use crate::models::auth::AuthUser; @@ -116,7 +116,7 @@ impl CampaignHandler { ) -> Result { Campaign::update(id, request_body, &mut transaction.tx).await?; transaction.tx.commit().await?; - Ok((StatusCode::OK, "Successfully updated campaign")) + Ok(AppMessage::OkMessage("Successfully updated campaign")) } /// Publishes a campaign by setting its published field to true. @@ -139,7 +139,7 @@ impl CampaignHandler { ) -> Result { Campaign::publish(id, &mut transaction.tx).await?; transaction.tx.commit().await?; - Ok((StatusCode::OK, "Successfully published campaign")) + Ok(AppMessage::OkMessage("Successfully published campaign")) } /// Updates a campaign's banner image. @@ -188,7 +188,7 @@ impl CampaignHandler { ) -> Result { Campaign::delete(id, &mut transaction.tx).await?; transaction.tx.commit().await?; - Ok((StatusCode::OK, "Successfully deleted campaign")) + Ok(AppMessage::OkMessage("Successfully deleted campaign")) } /// Creates a new role in a campaign. @@ -215,7 +215,7 @@ impl CampaignHandler { ) -> Result { Role::create(id, data, &mut transaction.tx, &mut state.snowflake_generator).await?; transaction.tx.commit().await?; - Ok((StatusCode::OK, "Successfully created role")) + Ok(AppMessage::OkMessage("Successfully created role")) } /// Retrieves all roles in a campaign. @@ -274,7 +274,7 @@ impl CampaignHandler { ) .await?; transaction.tx.commit().await?; - Ok((StatusCode::OK, "Successfully created application")) + Ok(AppMessage::OkMessage("Successfully created application")) } /// Retrieves all applications for a campaign. @@ -334,7 +334,7 @@ impl CampaignHandler { .await?; transaction.tx.commit().await?; - Ok((StatusCode::OK, "Successfully created offer")) + Ok(AppMessage::OkMessage("Successfully created offer")) } /// Retrieves all offers for a campaign. diff --git a/backend/server/src/handler/email_template.rs b/backend/server/src/handler/email_template.rs index e27d4cf19..c4ffe48a1 100644 --- a/backend/server/src/handler/email_template.rs +++ b/backend/server/src/handler/email_template.rs @@ -9,9 +9,10 @@ use crate::models::auth::EmailTemplateAdmin; use crate::models::email_template::EmailTemplate; use crate::models::error::ChaosError; use crate::models::transaction::DBTransaction; -use axum::extract::{Json, Path}; +use axum::extract::{Json, Path, State}; use axum::http::StatusCode; use axum::response::IntoResponse; +use crate::models::app::{AppMessage, AppState}; /// Handler for email template-related HTTP requests. pub struct EmailTemplateHandler; @@ -70,7 +71,7 @@ impl EmailTemplateHandler { .await?; transaction.tx.commit().await?; - Ok((StatusCode::OK, "Successfully updated email template")) + Ok(AppMessage::OkMessage("Successfully updated email template")) } /// Deletes an email template. @@ -94,6 +95,31 @@ impl EmailTemplateHandler { EmailTemplate::delete(id, &mut transaction.tx).await?; transaction.tx.commit().await?; - Ok((StatusCode::OK, "Successfully delete email template")) + Ok(AppMessage::OkMessage("Successfully deleted email template")) + } + + /// Duplicates an email template. + /// + /// This handler allows email template admins to duplicate templates. + /// + /// # Arguments + /// + /// * `_user` - The authenticated user (must be an email template admin) + /// * `id` - The ID of the template to delete + /// * `state` - The application state + /// + /// # Returns + /// + /// * `Result` - Success message or error + pub async fn duplicate( + _user: EmailTemplateAdmin, + Path(id): Path, + State(mut state): State, + mut transaction: DBTransaction<'_>, + ) -> Result { + EmailTemplate::duplicate(id, &mut transaction.tx, &mut state.snowflake_generator).await?; + + transaction.tx.commit().await?; + Ok(AppMessage::OkMessage("Successfully duplicated email template")) } } diff --git a/backend/server/src/handler/offer.rs b/backend/server/src/handler/offer.rs index ffbd54bcc..211f77887 100644 --- a/backend/server/src/handler/offer.rs +++ b/backend/server/src/handler/offer.rs @@ -5,7 +5,7 @@ //! - Replying to offers //! - Previewing and sending offer emails -use crate::models::app::AppState; +use crate::models::app::{AppMessage, AppState}; use crate::models::auth::{OfferAdmin, OfferRecipient}; use crate::models::error::ChaosError; use crate::models::offer::{Offer, OfferReply}; @@ -63,7 +63,7 @@ impl OfferHandler { Offer::delete(id, &mut transaction.tx).await?; transaction.tx.commit().await?; - Ok((StatusCode::OK, "Successfully deleted offer")) + Ok(AppMessage::OkMessage("Successfully deleted offer")) } /// Allows a recipient to reply to an offer. @@ -89,7 +89,7 @@ impl OfferHandler { Offer::reply(id, reply.accept, &mut transaction.tx).await?; transaction.tx.commit().await?; - Ok((StatusCode::OK, "Successfully accepted offer")) + Ok(AppMessage::OkMessage("Successfully accepted offer")) } /// Previews the email that will be sent for an offer. @@ -139,6 +139,6 @@ impl OfferHandler { Offer::send_offer(id, &mut transaction.tx, state.email_credentials).await?; transaction.tx.commit().await?; - Ok((StatusCode::OK, "Successfully sent offer")) + Ok(AppMessage::OkMessage("Successfully sent offer")) } } diff --git a/backend/server/src/handler/organisation.rs b/backend/server/src/handler/organisation.rs index f43e8bb65..b8a3099d5 100644 --- a/backend/server/src/handler/organisation.rs +++ b/backend/server/src/handler/organisation.rs @@ -7,7 +7,7 @@ //! - Email template management //! - Logo image handling -use crate::models::app::AppState; +use crate::models::app::{AppMessage, AppState}; use crate::models::auth::SuperUser; use crate::models::auth::{AuthUser, OrganisationAdmin}; use crate::models::campaign::{Campaign, NewCampaign}; @@ -55,7 +55,7 @@ impl OrganisationHandler { .await?; transaction.tx.commit().await?; - Ok((StatusCode::OK, "Successfully created organisation")) + Ok(AppMessage::OkMessage("Successfully created organisation")) } /// Checks if an organisation slug is available. @@ -79,7 +79,7 @@ impl OrganisationHandler { Organisation::check_slug_availability(data.slug, &mut transaction.tx).await?; transaction.tx.commit().await?; - Ok((StatusCode::OK, "Organisation slug is available")) + Ok(AppMessage::OkMessage("Organisation slug is available")) } /// Retrieves an organisation by its ID. @@ -150,7 +150,7 @@ impl OrganisationHandler { Organisation::delete(id, &mut transaction.tx).await?; transaction.tx.commit().await?; - Ok((StatusCode::OK, "Successfully deleted organisation")) + Ok(AppMessage::OkMessage("Successfully deleted organisation")) } /// Get all organisations that the logged in user is a Member of @@ -249,7 +249,7 @@ impl OrganisationHandler { Organisation::update_admins(id, request_body.members, &mut transaction.tx).await?; transaction.tx.commit().await?; - Ok((StatusCode::OK, "Successfully updated organisation members")) + Ok(AppMessage::OkMessage("Successfully updated organisation members")) } /// Updates the member list of an organisation. @@ -275,7 +275,7 @@ impl OrganisationHandler { Organisation::update_members(id, request_body.members, &mut transaction.tx).await?; transaction.tx.commit().await?; - Ok((StatusCode::OK, "Successfully updated organisation members")) + Ok(AppMessage::OkMessage("Successfully updated organisation members")) } /// Removes an admin from an organisation. @@ -445,7 +445,7 @@ impl OrganisationHandler { Campaign::check_slug_availability(organisation_id, data.slug, &mut transaction.tx).await?; transaction.tx.commit().await?; - Ok((StatusCode::OK, "Campaign slug is available")) + Ok(AppMessage::OkMessage("Campaign slug is available")) } /// Creates a new email template for an organisation. @@ -480,7 +480,7 @@ impl OrganisationHandler { .await?; transaction.tx.commit().await?; - Ok((StatusCode::OK, "Successfully created email template")) + Ok(AppMessage::OkMessage("Successfully created email template")) } /// Retrieves all email templates for an organisation. diff --git a/backend/server/src/handler/question.rs b/backend/server/src/handler/question.rs index e3533dafb..6b234b729 100644 --- a/backend/server/src/handler/question.rs +++ b/backend/server/src/handler/question.rs @@ -5,7 +5,7 @@ //! - Updating and deleting questions //! - Managing role-specific and common questions -use crate::models::app::AppState; +use crate::models::app::{AppMessage, AppState}; use crate::models::auth::{AuthUser, CampaignAdmin, QuestionAdmin}; use crate::models::error::ChaosError; use crate::models::question::{NewQuestion, Question}; @@ -159,7 +159,7 @@ impl QuestionHandler { transaction.tx.commit().await?; - Ok((StatusCode::OK, "Successfully updated question")) + Ok(AppMessage::OkMessage("Successfully updated question")) } /// Deletes a question. @@ -185,6 +185,6 @@ impl QuestionHandler { transaction.tx.commit().await?; - Ok((StatusCode::OK, "Successfully deleted question")) + Ok(AppMessage::OkMessage("Successfully deleted question")) } } diff --git a/backend/server/src/handler/rating.rs b/backend/server/src/handler/rating.rs index 1ef1ade58..a14e87701 100644 --- a/backend/server/src/handler/rating.rs +++ b/backend/server/src/handler/rating.rs @@ -5,7 +5,7 @@ //! - Retrieving rating details //! - Deleting ratings -use crate::models::app::AppState; +use crate::models::app::{AppMessage, AppState}; use crate::models::auth::{ ApplicationReviewerGivenApplicationId, ApplicationReviewerGivenRatingId, RatingCreator, }; @@ -36,7 +36,7 @@ impl RatingHandler { ) .await?; transaction.tx.commit().await?; - Ok((StatusCode::OK, "Successfully created rating")) + Ok(AppMessage::OkMessage("Successfully created rating")) } /// Updates an existing rating. @@ -63,7 +63,7 @@ impl RatingHandler { ) -> Result { Rating::update(rating_id, updated_rating, &mut transaction.tx).await?; transaction.tx.commit().await?; - Ok((StatusCode::OK, "Successfully updated rating")) + Ok(AppMessage::OkMessage("Successfully updated rating")) } /// Retrieves the details of a specific rating. @@ -113,6 +113,6 @@ impl RatingHandler { ) -> Result { Rating::delete(rating_id, &mut transaction.tx).await?; transaction.tx.commit().await?; - Ok((StatusCode::OK, "Successfully deleted rating")) + Ok(AppMessage::OkMessage("Successfully deleted rating")) } } diff --git a/backend/server/src/handler/role.rs b/backend/server/src/handler/role.rs index 06a0f3591..2b76da7c3 100644 --- a/backend/server/src/handler/role.rs +++ b/backend/server/src/handler/role.rs @@ -13,6 +13,7 @@ use crate::models::transaction::DBTransaction; use axum::extract::{Json, Path}; use axum::http::StatusCode; use axum::response::IntoResponse; +use crate::models::app::AppMessage; /// Handler for role-related HTTP requests. pub struct RoleHandler; @@ -63,7 +64,7 @@ impl RoleHandler { Role::delete(id, &mut transaction.tx).await?; transaction.tx.commit().await?; - Ok((StatusCode::OK, "Successfully deleted role")) + Ok(AppMessage::OkMessage("Successfully deleted role")) } /// Updates a role. @@ -89,7 +90,7 @@ impl RoleHandler { Role::update(id, data, &mut transaction.tx).await?; transaction.tx.commit().await?; - Ok((StatusCode::OK, "Successfully updated role")) + Ok(AppMessage::OkMessage("Successfully updated role")) } /// Retrieves all applications for a specific role. diff --git a/backend/server/src/handler/user.rs b/backend/server/src/handler/user.rs index 5d15186e8..238cf7ba5 100644 --- a/backend/server/src/handler/user.rs +++ b/backend/server/src/handler/user.rs @@ -10,6 +10,7 @@ use crate::models::user::{User, UserDegree, UserGender, UserName, UserPronouns, use axum::extract::{Json}; use axum::http::StatusCode; use axum::response::IntoResponse; +use crate::models::app::AppMessage; use crate::models::transaction::DBTransaction; /// Handler for user-related HTTP requests. @@ -59,7 +60,7 @@ impl UserHandler { User::update_name(user.user_id, request_body.name, &mut transaction.tx).await?; transaction.tx.commit().await?; - Ok((StatusCode::OK, "Updated username")) + Ok(AppMessage::OkMessage("Updated username")) } /// Updates the user's pronouns. @@ -83,7 +84,7 @@ impl UserHandler { User::update_pronouns(user.user_id, request_body.pronouns, &mut transaction.tx).await?; transaction.tx.commit().await?; - Ok((StatusCode::OK, "Updated pronouns")) + Ok(AppMessage::OkMessage("Updated pronouns")) } /// Updates the user's gender. @@ -107,7 +108,7 @@ impl UserHandler { User::update_gender(user.user_id, request_body.gender, &mut transaction.tx).await?; transaction.tx.commit().await?; - Ok((StatusCode::OK, "Updated gender")) + Ok(AppMessage::OkMessage("Updated gender")) } /// Updates the user's zid. @@ -131,7 +132,7 @@ impl UserHandler { User::update_zid(user.user_id, request_body.zid, &mut transaction.tx).await?; transaction.tx.commit().await?; - Ok((StatusCode::OK, "Updated zid")) + Ok(AppMessage::OkMessage("Updated zid")) } /// Updates the user's degree information. @@ -161,6 +162,6 @@ impl UserHandler { .await?; transaction.tx.commit().await?; - Ok((StatusCode::OK, "Updated user degree")) + Ok(AppMessage::OkMessage("Updated user degree")) } } diff --git a/backend/server/src/lib.rs b/backend/server/src/lib.rs index c849d58e8..dce9c9301 100644 --- a/backend/server/src/lib.rs +++ b/backend/server/src/lib.rs @@ -1,3 +1,4 @@ pub mod handler; pub mod models; -pub mod service; \ No newline at end of file +pub mod service; +pub(crate) mod constants; \ No newline at end of file diff --git a/backend/server/src/main.rs b/backend/server/src/main.rs index 105fc5a0e..0c8c86e97 100644 --- a/backend/server/src/main.rs +++ b/backend/server/src/main.rs @@ -4,6 +4,7 @@ use crate::models::error::ChaosError; mod handler; mod models; mod service; +pub(crate) mod constants; #[tokio::main] async fn main() -> Result<(), ChaosError> { diff --git a/backend/server/src/models/app.rs b/backend/server/src/models/app.rs index 5c03accde..23a8dbe47 100644 --- a/backend/server/src/models/app.rs +++ b/backend/server/src/models/app.rs @@ -397,6 +397,10 @@ pub async fn app() -> Result { .patch(EmailTemplateHandler::update) .delete(EmailTemplateHandler::delete), ) + .route( + "/api/v1/email_template/:template_id/duplicate", + post(EmailTemplateHandler::duplicate) + ) .route( "/api/v1/offer/:offer_id", get(OfferHandler::get) diff --git a/backend/server/src/models/email_template.rs b/backend/server/src/models/email_template.rs index 8afe9a9cd..0f9f24680 100644 --- a/backend/server/src/models/email_template.rs +++ b/backend/server/src/models/email_template.rs @@ -11,6 +11,10 @@ use serde::{Deserialize, Serialize}; use sqlx::{Postgres, Transaction}; use std::collections::HashMap; use std::ops::DerefMut; +use nanoid::nanoid; +use snowflake::SnowflakeIdGenerator; +use crate::models::organisation::Organisation; +use crate::constants::NANOID_ALPHABET; /// Represents an email template in the database. /// @@ -23,10 +27,10 @@ use std::ops::DerefMut; #[derive(Deserialize, Serialize)] pub struct EmailTemplate { /// Unique identifier for the template - #[serde(serialize_with = "crate::models::serde_string::serialize")] + #[serde(serialize_with = "crate::models::serde_string::serialize", deserialize_with = "crate::models::serde_string::deserialize")] pub id: i64, /// ID of the organisation that owns this template - #[serde(serialize_with = "crate::models::serde_string::serialize")] + #[serde(serialize_with = "crate::models::serde_string::serialize", deserialize_with = "crate::models::serde_string::deserialize")] pub organisation_id: i64, /// Display name of the template pub name: String, @@ -92,7 +96,7 @@ impl EmailTemplate { ) -> Result, ChaosError> { let templates = sqlx::query_as!( EmailTemplate, - "SELECT * FROM email_templates WHERE organisation_id = $1", + "SELECT * FROM email_templates WHERE organisation_id = $1 ORDER BY id", organisation_id ) .fetch_all(transaction.deref_mut()) @@ -154,6 +158,33 @@ impl EmailTemplate { Ok(()) } + /// Duplicates an email template. + /// + /// # Arguments + /// * `id` - The ID of the template to duplicate + /// * `pool` - A reference to the database connection pool + /// + /// # Returns + /// Returns a `Result` containing either: + /// * `Ok(())` - If the deletion was successful + /// * `Err(ChaosError)` - An error if the deletion fails + pub async fn duplicate(id: i64, transaction: &mut Transaction<'_, Postgres>, snowflake_generator: &mut SnowflakeIdGenerator,) -> Result<(), ChaosError> { + let template = EmailTemplate::get(id, transaction).await?; + + let duplicate_prevention_id = nanoid!(6, &NANOID_ALPHABET); + let new_template_name = format!("{} (Copy {})", template.name, duplicate_prevention_id); + Organisation::create_email_template( + template.organisation_id, + new_template_name, + template.template_subject, + template.template_body, + transaction, + snowflake_generator + ).await?; + + Ok(()) + } + /// Generates an email using a template and provided data. /// /// # Arguments diff --git a/backend/server/src/models/error.rs b/backend/server/src/models/error.rs index 014f6a9d1..c68098c9e 100644 --- a/backend/server/src/models/error.rs +++ b/backend/server/src/models/error.rs @@ -4,8 +4,7 @@ //! It provides a unified error handling system that covers both application-specific //! errors and errors from external dependencies. -use axum::http::StatusCode; -use axum::response::{IntoResponse, Redirect, Response}; +use axum::response::{IntoResponse, Response}; use crate::models::app::AppMessage; /// Custom error enum for Chaos. diff --git a/frontend-nextjs/bun.lock b/frontend-nextjs/bun.lock index 45423db3a..6340e8b6b 100644 --- a/frontend-nextjs/bun.lock +++ b/frontend-nextjs/bun.lock @@ -5,6 +5,8 @@ "": { "name": "frontend-nextjs", "dependencies": { + "@radix-ui/react-alert-dialog": "^1.1.15", + "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-label": "^2.1.8", @@ -12,6 +14,7 @@ "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.11", "@tanstack/react-table": "^8.21.3", @@ -140,8 +143,12 @@ "@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="], + "@radix-ui/react-alert-dialog": ["@radix-ui/react-alert-dialog@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dialog": "1.1.15", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw=="], + "@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w=="], + "@radix-ui/react-collapsible": ["@radix-ui/react-collapsible@1.1.12", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA=="], + "@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw=="], "@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="], @@ -184,6 +191,8 @@ "@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="], + "@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.13", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A=="], + "@radix-ui/react-tooltip": ["@radix-ui/react-tooltip@1.2.8", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg=="], "@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg=="], @@ -492,6 +501,8 @@ "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], + "@radix-ui/react-alert-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], "@radix-ui/react-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], diff --git a/frontend-nextjs/package.json b/frontend-nextjs/package.json index 97f9c2ed2..d42dc77fa 100644 --- a/frontend-nextjs/package.json +++ b/frontend-nextjs/package.json @@ -8,6 +8,8 @@ "start": "next start" }, "dependencies": { + "@radix-ui/react-alert-dialog": "^1.1.15", + "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-label": "^2.1.8", @@ -15,6 +17,7 @@ "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.11", "@tanstack/react-table": "^8.21.3", diff --git a/frontend-nextjs/src/app/[lang]/dashboard/organisation/[orgId]/campaigns/[campaignId]/campaign-details.tsx b/frontend-nextjs/src/app/[lang]/dashboard/organisation/[orgId]/campaigns/[campaignId]/campaign-details.tsx index d52a44c37..0af5a02d2 100644 --- a/frontend-nextjs/src/app/[lang]/dashboard/organisation/[orgId]/campaigns/[campaignId]/campaign-details.tsx +++ b/frontend-nextjs/src/app/[lang]/dashboard/organisation/[orgId]/campaigns/[campaignId]/campaign-details.tsx @@ -18,6 +18,7 @@ import { } from "@/components/ui/table" import Link from "next/link"; import { getOrganisationUserRole } from "@/models/organisation"; +import CopyButton from "@/components/copy-button"; export default function CampaignDetails({ campaignId, descriptionHtml, orgId, dict }: { campaignId: string, descriptionHtml: string, orgId: string, dict: any }) { const editingMode = true; @@ -48,12 +49,12 @@ export default function CampaignDetails({ campaignId, descriptionHtml, orgId, di - - + {userRole?.role === "Admin" && ( diff --git a/frontend-nextjs/src/app/[lang]/dashboard/organisation/[orgId]/campaigns/[campaignId]/page.tsx b/frontend-nextjs/src/app/[lang]/dashboard/organisation/[orgId]/campaigns/[campaignId]/page.tsx index 5214b9586..a9956d625 100644 --- a/frontend-nextjs/src/app/[lang]/dashboard/organisation/[orgId]/campaigns/[campaignId]/page.tsx +++ b/frontend-nextjs/src/app/[lang]/dashboard/organisation/[orgId]/campaigns/[campaignId]/page.tsx @@ -10,7 +10,7 @@ import CampaignDetails from './campaign-details'; import { remark } from 'remark'; import html from 'remark-html'; - export default async function CampaignDetailsPage({ params }: { params: { campaignId: string, lang: string } }) { + export default async function CampaignDetailsPage({ params }: { params: Promise<{ campaignId: string, lang: string }> }) { const { campaignId, lang } = await params; const dict = await getDictionary(lang); const queryClient = new QueryClient(); diff --git a/frontend-nextjs/src/app/[lang]/dashboard/organisation/[orgId]/campaigns/[campaignId]/review/application-details.tsx b/frontend-nextjs/src/app/[lang]/dashboard/organisation/[orgId]/campaigns/[campaignId]/review/application-details.tsx index 5897e0153..5c89764ca 100644 --- a/frontend-nextjs/src/app/[lang]/dashboard/organisation/[orgId]/campaigns/[campaignId]/review/application-details.tsx +++ b/frontend-nextjs/src/app/[lang]/dashboard/organisation/[orgId]/campaigns/[campaignId]/review/application-details.tsx @@ -79,6 +79,10 @@ export default function ApplicationDetailsComponent({ applicationId, campaignId, if (!rating) { sendingRating = originalRating; } + + if (!comment) { + sendingComment = originalComment ?? ""; + } if (hasRated) { await updateApplicationRating(applicationId, sendingRating, sendingComment); diff --git a/frontend-nextjs/src/app/[lang]/dashboard/organisation/[orgId]/campaigns/columns.tsx b/frontend-nextjs/src/app/[lang]/dashboard/organisation/[orgId]/campaigns/columns.tsx index d2a10fe3f..717697680 100644 --- a/frontend-nextjs/src/app/[lang]/dashboard/organisation/[orgId]/campaigns/columns.tsx +++ b/frontend-nextjs/src/app/[lang]/dashboard/organisation/[orgId]/campaigns/columns.tsx @@ -17,6 +17,7 @@ import { } from "@/components/ui/dropdown-menu" import Link from "next/link"; import { getDictionary } from "@/app/[lang]/dictionaries"; +import { Badge } from "@/components/ui/badge"; export function getColumns(userRole: OrganisationUserRole, dict: any): ColumnDef[] { return [ @@ -42,8 +43,15 @@ export function getColumns(userRole: OrganisationUserRole, dict: any): ColumnDef }, }, { - header: dict.common.published, + header: dict.common.status, accessorKey: "published", + cell: ({ row }) => { + return (row.original.published ? ( + {dict.dashboard.campaigns.published} + ) : ( + {dict.dashboard.campaigns.draft} + )); + }, }, { id: "actions", diff --git a/frontend-nextjs/src/app/[lang]/dashboard/organisation/[orgId]/campaigns/page.tsx b/frontend-nextjs/src/app/[lang]/dashboard/organisation/[orgId]/campaigns/page.tsx index 72ea678ff..88fc873c9 100644 --- a/frontend-nextjs/src/app/[lang]/dashboard/organisation/[orgId]/campaigns/page.tsx +++ b/frontend-nextjs/src/app/[lang]/dashboard/organisation/[orgId]/campaigns/page.tsx @@ -8,7 +8,7 @@ import Campaigns from './campaigns'; import { getOrganisationCampaigns } from '@/models/organisation'; import { getDictionary } from '@/app/[lang]/dictionaries'; - export default async function CampaignsPage({ params }: { params: { orgId: string, lang: string } }) { + export default async function CampaignsPage({ params }: { params: Promise<{ orgId: string, lang: string }> }) { const { orgId, lang } = await params; const dict = await getDictionary(lang); const queryClient = new QueryClient(); diff --git a/frontend-nextjs/src/app/[lang]/dashboard/organisation/[orgId]/layout.tsx b/frontend-nextjs/src/app/[lang]/dashboard/organisation/[orgId]/layout.tsx index 1d3e0f1d0..dc96e74f2 100644 --- a/frontend-nextjs/src/app/[lang]/dashboard/organisation/[orgId]/layout.tsx +++ b/frontend-nextjs/src/app/[lang]/dashboard/organisation/[orgId]/layout.tsx @@ -24,7 +24,7 @@ export async function generateMetadata( } } -export default async function Layout({ children, params }: { children: React.ReactNode, params: { orgId: string,lang: string } }) { +export default async function Layout({ children, params }: { children: React.ReactNode, params: Promise<{ orgId: string,lang: string }> }) { const { orgId, lang } = await params; const dict = await getDictionary(lang); @@ -54,9 +54,9 @@ export default async function Layout({ children, params }: { children: React.Rea -
+
-
+
{children}
diff --git a/frontend-nextjs/src/app/[lang]/dashboard/organisation/[orgId]/templates/[templateId]/edit/edit-template.tsx b/frontend-nextjs/src/app/[lang]/dashboard/organisation/[orgId]/templates/[templateId]/edit/edit-template.tsx new file mode 100644 index 000000000..36648e8da --- /dev/null +++ b/frontend-nextjs/src/app/[lang]/dashboard/organisation/[orgId]/templates/[templateId]/edit/edit-template.tsx @@ -0,0 +1,24 @@ +"use client"; + +import { getEmailTemplate, updateEmailTemplate } from "@/models/email"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import TemplateForm from "../../template-form"; + +export default function TemplateEditForm({ templateId, orgId, dict }: { templateId: string, orgId: string, dict: any }) { + const queryClient = useQueryClient(); + + const { data: template } = useQuery({ + queryKey: [`${templateId}-email-template`], + queryFn: () => getEmailTemplate(templateId), + }); + + const submitData = async (templateId: string, name: string, subject: string, body: string) => { + await updateEmailTemplate(templateId, { id: templateId, organisation_id: orgId, name, template_subject: subject, template_body: body }); + await queryClient.invalidateQueries({ queryKey: [`${templateId}-email-template`] }); + await queryClient.invalidateQueries({ queryKey: [`${orgId}-email-templates`] }); + } + + return ( + + ) +} \ No newline at end of file diff --git a/frontend-nextjs/src/app/[lang]/dashboard/organisation/[orgId]/templates/[templateId]/edit/page.tsx b/frontend-nextjs/src/app/[lang]/dashboard/organisation/[orgId]/templates/[templateId]/edit/page.tsx new file mode 100644 index 000000000..b67bf24f0 --- /dev/null +++ b/frontend-nextjs/src/app/[lang]/dashboard/organisation/[orgId]/templates/[templateId]/edit/page.tsx @@ -0,0 +1,23 @@ +import { getEmailTemplate, } from "@/models/email" +import { getDictionary } from "@/app/[lang]/dictionaries" +import { dehydrate, HydrationBoundary, QueryClient } from "@tanstack/react-query" +import TemplateEditForm from "./edit-template"; + + +export default async function TemplateEditPage({ params }: { params: Promise<{ orgId: string, templateId: string, lang: string }> }) { + const { orgId, templateId, lang } = await params; + const dict = await getDictionary(lang); + const queryClient = new QueryClient(); + + await queryClient.prefetchQuery({ + queryKey: [`${templateId}-email-template`], + queryFn: () => getEmailTemplate(templateId), + }); + + + return ( + + + + ) +} diff --git a/frontend-nextjs/src/app/[lang]/dashboard/organisation/[orgId]/templates/new/new-template.tsx b/frontend-nextjs/src/app/[lang]/dashboard/organisation/[orgId]/templates/new/new-template.tsx new file mode 100644 index 000000000..d06aebd61 --- /dev/null +++ b/frontend-nextjs/src/app/[lang]/dashboard/organisation/[orgId]/templates/new/new-template.tsx @@ -0,0 +1,20 @@ +"use client"; + +import { createEmailTemplate, getEmailTemplate, updateEmailTemplate } from "@/models/email"; +import { useQueryClient } from "@tanstack/react-query"; +import TemplateForm from "../template-form"; +import { redirect } from "next/navigation"; + +export default function TemplateNewForm({ orgId, dict }: { orgId: string, dict: any }) { + const queryClient = useQueryClient(); + + const submitData = async (templateId: string, name: string, subject: string, body: string) => { + await createEmailTemplate(orgId, { name, template_subject: subject, template_body: body }); + await queryClient.invalidateQueries({ queryKey: [`${orgId}-email-templates`] }); + redirect(`/dashboard/organisation/${orgId}/templates`); + } + + return ( + + ) +} \ No newline at end of file diff --git a/frontend-nextjs/src/app/[lang]/dashboard/organisation/[orgId]/templates/new/page.tsx b/frontend-nextjs/src/app/[lang]/dashboard/organisation/[orgId]/templates/new/page.tsx new file mode 100644 index 000000000..1ce484b3e --- /dev/null +++ b/frontend-nextjs/src/app/[lang]/dashboard/organisation/[orgId]/templates/new/page.tsx @@ -0,0 +1,17 @@ +import { getDictionary } from "@/app/[lang]/dictionaries" +import { dehydrate, HydrationBoundary, QueryClient } from "@tanstack/react-query" +import TemplateNewForm from "./new-template"; + + +export default async function TemplateNewPage({ params }: { params: Promise<{ orgId: string, lang: string }> }) { + const { orgId, lang } = await params; + const dict = await getDictionary(lang); + const queryClient = new QueryClient(); + + + return ( + + + + ) +} diff --git a/frontend-nextjs/src/app/[lang]/dashboard/organisation/[orgId]/templates/page.tsx b/frontend-nextjs/src/app/[lang]/dashboard/organisation/[orgId]/templates/page.tsx new file mode 100644 index 000000000..3b1158840 --- /dev/null +++ b/frontend-nextjs/src/app/[lang]/dashboard/organisation/[orgId]/templates/page.tsx @@ -0,0 +1,23 @@ +import { getOrganisationEmailTemplates, } from "@/models/email" +import { getDictionary } from "@/app/[lang]/dictionaries" +import { dehydrate, HydrationBoundary, QueryClient } from "@tanstack/react-query" +import EmailTemplates from "./templates" + + +export default async function EmailTemplatesPage({ params }: { params: Promise<{ orgId: string, lang: string }> }) { + const { orgId, lang } = await params; + const dict = await getDictionary(lang); + const queryClient = new QueryClient(); + + await queryClient.prefetchQuery({ + queryKey: [`${orgId}-email-templates`], + queryFn: () => getOrganisationEmailTemplates(orgId), + }); + + + return ( + + + + ) +} diff --git a/frontend-nextjs/src/app/[lang]/dashboard/organisation/[orgId]/templates/template-form.tsx b/frontend-nextjs/src/app/[lang]/dashboard/organisation/[orgId]/templates/template-form.tsx new file mode 100644 index 000000000..d2b8d6ee3 --- /dev/null +++ b/frontend-nextjs/src/app/[lang]/dashboard/organisation/[orgId]/templates/template-form.tsx @@ -0,0 +1,69 @@ +"use client"; + +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { Button } from "@/components/ui/button"; +import { ArrowLeft } from "lucide-react"; +import { ButtonGroup } from "@/components/ui/button-group"; +import Link from "next/link"; +import { EmailTemplate, getEmailTemplate, templateVariables, updateEmailTemplate } from "@/models/email"; +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; +import { useState } from "react"; +import { Textarea } from "@/components/ui/textarea"; + +export default function TemplateForm({ templateId, template, orgId, dict, submitData }: { templateId: string, template?: EmailTemplate, orgId: string, dict: any, submitData: (templateId: string, name: string, subject: string, body: string) => Promise }) { + const [name, setName] = useState(template?.name ?? ""); + const [subject, setSubject] = useState(template?.template_subject ?? ""); + const [body, setBody] = useState(template?.template_body ?? ""); + + const [saving, setSaving] = useState(false); + + const handleSave = async () => { + setSaving(true); + await submitData(templateId, name, subject, body); + setSaving(false); + } + + + return ( +
+
+
+ +
+ + {dict.common.back} +
+ +

{dict.dashboard.email.edit_template}

+
+
+
+
+ + setName(e.target.value)} /> +
+
+ + setSubject(e.target.value)} /> +
+
+ + + {templateVariables.map((variable) => { + return ( + + ) + })} + +