diff --git a/backend/database-seeding/src/seeder.rs b/backend/database-seeding/src/seeder.rs index bae6e77a1..617ecd0df 100644 --- a/backend/database-seeding/src/seeder.rs +++ b/backend/database-seeding/src/seeder.rs @@ -252,6 +252,72 @@ pub async fn seed_database(dev_email: String, mut seeder: Seeder) { ) .await.expect("Failed seeding Question 3"); + let question_id_4 = Question::create( + campaign_id, + "Where did you hear about DevSoc from?".to_string(), + Some("This is a general question for all roles".to_string()), + true, + None, + true, + QuestionData::MultiChoice( + MultiOptionData { + options: vec![ + MultiOptionQuestionOption { + id: 0, + text: "Email".to_string(), + display_order: 1, + }, + MultiOptionQuestionOption { + id: 0, + text: "Word of Mouth".to_string(), + display_order: 2, + }, + MultiOptionQuestionOption { + id: 0, + text: "Social Media".to_string(), + display_order: 3, + }, + ] + } + ), + &mut seeder.app_state.snowflake_generator, + &mut tx, + ) + .await.expect("Failed seeding Question 4"); + + let question_id_5 = Question::create( + campaign_id, + "Rank these programming languages?".to_string(), + Some("This is a general question for all technical roles".to_string()), + false, + Some(vec![role_id_1, role_id_2]), + true, + QuestionData::Ranking( + MultiOptionData { + options: vec![ + MultiOptionQuestionOption { + id: 0, + text: "Rust".to_string(), + display_order: 1, + }, + MultiOptionQuestionOption { + id: 0, + text: "JavaScript".to_string(), + display_order: 2, + }, + MultiOptionQuestionOption { + id: 0, + text: "Python".to_string(), + display_order: 3, + }, + ] + } + ), + &mut seeder.app_state.snowflake_generator, + &mut tx, + ) + .await.expect("Failed seeding Question 4"); + let application_id_1 = Application::create( campaign_id, 2, diff --git a/backend/server/src/models/question.rs b/backend/server/src/models/question.rs index ab95247c9..96d9417fe 100644 --- a/backend/server/src/models/question.rs +++ b/backend/server/src/models/question.rs @@ -131,17 +131,26 @@ impl Question { .await?; if !common { - for role in roles.unwrap_or_default() { - sqlx::query!( + if let Some(roles) = roles { + if roles.len() == 0 { + return Err(ChaosError::BadRequestWithMessage("Question must either be common or assigned to at least one role".to_string())); + } + + for role in roles { + sqlx::query!( " INSERT INTO question_roles (question_id, role_id) VALUES ($1, $2) ", id, role ) - .execute(transaction.deref_mut()) - .await?; + .execute(transaction.deref_mut()) + .await?; + } + } else { + return Err(ChaosError::BadRequestWithMessage("Question must either be common or assigned to at least one role".to_string())); } + } Ok(id) @@ -159,7 +168,7 @@ impl Question { q.title, q.description, q.common, - COALESCE(array_remove(array_agg(qr.role_id), NULL), '{}') AS "roles!: Vec", + COALESCE(array_remove(array_agg(DISTINCT qr.role_id), NULL), '{}') AS "roles!: Vec", q.required, q.question_type AS "question_type: QuestionType", q.created_at, @@ -220,7 +229,7 @@ impl Question { q.title, q.description, q.common, - COALESCE(array_remove(array_agg(qr.role_id), NULL), '{}') AS "roles!: Vec", + COALESCE(array_remove(array_agg(DISTINCT qr.role_id), NULL), '{}') AS "roles!: Vec", q.required, q.question_type AS "question_type: QuestionType", q.created_at, @@ -288,19 +297,22 @@ impl Question { q.title, q.description, q.common, - COALESCE(array_remove(array_agg(qr.role_id), NULL), '{}') AS "roles!: Vec", + COALESCE( + (SELECT array_agg(qr.role_id) FROM question_roles qr WHERE qr.question_id = q.id), + '{}' + ) AS "roles!: Vec", q.required, q.question_type AS "question_type: QuestionType", q.created_at, q.updated_at, - to_jsonb( - array_agg( - jsonb_build_object( - 'id', mod.id, - 'display_order', mod.display_order, - 'text', mod.text - ) ORDER BY mod.display_order - ) FILTER (WHERE mod.id IS NOT NULL) + ( + SELECT to_jsonb(array_agg(jsonb_build_object( + 'id', mod.id, + 'display_order', mod.display_order, + 'text', mod.text + ) ORDER BY mod.display_order)) + FROM multi_option_question_options mod + WHERE mod.question_id = q.id ) AS "multi_option_data: Json>" FROM questions q @@ -309,7 +321,9 @@ impl Question { LEFT JOIN multi_option_question_options mod ON q.id = mod.question_id AND q.question_type IN ('MultiChoice', 'MultiSelect', 'DropDown', 'Ranking') - WHERE q.campaign_id = $1 AND q.common = false AND qr.role_id = $2 + WHERE q.campaign_id = $1 AND q.common = false AND EXISTS ( + SELECT 1 FROM question_roles qr_check WHERE qr_check.question_id = q.id AND qr_check.role_id = $2 + ) GROUP BY q.id "#, @@ -356,7 +370,7 @@ impl Question { q.title, q.description, q.common, - COALESCE(array_remove(array_agg(qr.role_id), NULL), '{}') AS "roles!: Vec", + COALESCE(array_remove(array_agg(DISTINCT qr.role_id), NULL), '{}') AS "roles!: Vec", q.required, q.question_type AS "question_type: QuestionType", q.created_at, @@ -540,6 +554,7 @@ pub struct MultiOptionData { #[derive(Deserialize, Serialize)] pub struct MultiOptionQuestionOption { #[serde(serialize_with = "crate::models::serde_string::serialize")] + #[serde(deserialize_with = "crate::models::serde_string::deserialize")] pub id: i64, pub display_order: i32, pub text: String, diff --git a/backend/server/src/models/serde_string.rs b/backend/server/src/models/serde_string.rs index da9d2b2bf..4381a30fe 100644 --- a/backend/server/src/models/serde_string.rs +++ b/backend/server/src/models/serde_string.rs @@ -23,8 +23,13 @@ pub fn deserialize<'de, D>(deserializer: D) -> Result where D: Deserializer<'de>, { - let s = String::deserialize(deserializer)?; - s.parse::().map_err(Error::custom) + match serde_json::Value::deserialize(deserializer)? { + serde_json::Value::String(s) => s.parse::().map_err(Error::custom), + serde_json::Value::Number(n) => n + .as_i64() + .ok_or_else(|| Error::custom("number out of range for i64")), + _ => Err(Error::custom("expected string or number")), + } } pub fn deserialize_vec<'de, D>(deserializer: D) -> Result, D::Error> diff --git a/frontend-nextjs/bun.lock b/frontend-nextjs/bun.lock index a7ed991c0..9e6f6eb5c 100644 --- a/frontend-nextjs/bun.lock +++ b/frontend-nextjs/bun.lock @@ -5,6 +5,7 @@ "": { "name": "frontend", "dependencies": { + "@hello-pangea/dnd": "^18.0.1", "@lexical/react": "^0.39.0", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-collapsible": "^1.1.12", @@ -18,10 +19,12 @@ "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tooltip": "^1.2.8", + "@sapphire/snowflake": "^3.5.5", "@tanstack/react-query": "^5.90.13", "@tanstack/react-table": "^8.21.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "cmdk": "^1.1.1", "date-fns": "^4.1.0", "lexical": "^0.39.0", "lucide-react": "^0.562.0", @@ -77,6 +80,8 @@ "@floating-ui/utils": ["@floating-ui/utils@0.2.10", "", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="], + "@hello-pangea/dnd": ["@hello-pangea/dnd@18.0.1", "", { "dependencies": { "@babel/runtime": "^7.26.7", "css-box-model": "^1.2.1", "raf-schd": "^4.0.3", "react-redux": "^9.2.0", "redux": "^5.0.1" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-xojVWG8s/TGrKT1fC8K2tIWeejJYTAeJuj36zM//yEm/ZrnZUSFGS15BpO+jGZT1ybWvyXmeDJwPYb4dhWlbZQ=="], + "@img/colour": ["@img/colour@1.0.0", "", {}, "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw=="], "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="], @@ -279,6 +284,8 @@ "@radix-ui/rect": ["@radix-ui/rect@1.1.1", "", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="], + "@sapphire/snowflake": ["@sapphire/snowflake@3.5.5", "", {}, "sha512-xzvBr1Q1c4lCe7i6sRnrofxeO1QTP/LKQ6A6qy0iB4x5yfiSfARMEQEghojzTNALDTcv8En04qYNIco9/K9eZQ=="], + "@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="], "@tailwindcss/node": ["@tailwindcss/node@4.1.18", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.6.1", "lightningcss": "1.30.2", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.1.18" } }, "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ=="], @@ -335,6 +342,8 @@ "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], + "@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.6", "", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="], + "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="], "aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="], @@ -363,8 +372,12 @@ "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], + "cmdk": ["cmdk@1.1.1", "", { "dependencies": { "@radix-ui/react-compose-refs": "^1.1.1", "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-id": "^1.1.0", "@radix-ui/react-primitive": "^2.0.2" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "react-dom": "^18 || ^19 || ^19.0.0-rc" } }, "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg=="], + "comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="], + "css-box-model": ["css-box-model@1.2.1", "", { "dependencies": { "tiny-invariant": "^1.0.6" } }, "sha512-a7Vr4Q/kd/aw96bnJG332W9V9LkJO69JRcaCYDUqjp6/z0w6VcZjgAcTbgFxEPfBgdnAwlh3iwu+hLopa+flJw=="], + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], "date-fns": ["date-fns@4.1.0", "", {}, "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="], @@ -519,6 +532,8 @@ "property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="], + "raf-schd": ["raf-schd@4.0.3", "", {}, "sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ=="], + "react": ["react@19.2.3", "", {}, "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA=="], "react-day-picker": ["react-day-picker@9.13.0", "", { "dependencies": { "@date-fns/tz": "^1.4.1", "date-fns": "^4.1.0", "date-fns-jalali": "^4.1.0-0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-euzj5Hlq+lOHqI53NiuNhCP8HWgsPf/bBAVijR50hNaY1XwjKjShAnIe8jm8RD2W9IJUvihDIZ+KrmqfFzNhFQ=="], @@ -533,6 +548,8 @@ "react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], + "react-redux": ["react-redux@9.2.0", "", { "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" }, "peerDependencies": { "@types/react": "^18.2.25 || ^19", "react": "^18.0 || ^19", "redux": "^5.0.0" }, "optionalPeers": ["@types/react", "redux"] }, "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g=="], + "react-remove-scroll": ["react-remove-scroll@2.7.2", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="], "react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="], @@ -541,6 +558,8 @@ "react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="], + "redux": ["redux@5.0.1", "", {}, "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w=="], + "remark": ["remark@15.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.0" } }, "sha512-Eht5w30ruCXgFmxVUSlNWQ9iiimq07URKeFS3hNc8cUWy1llX4KDWfyEDZRycMc+znsN9Ux5/tJ/BFdgdOwA3A=="], "remark-html": ["remark-html@16.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "hast-util-sanitize": "^5.0.0", "hast-util-to-html": "^9.0.0", "mdast-util-to-hast": "^13.0.0", "unified": "^11.0.0" } }, "sha512-B9JqA5i0qZe0Nsf49q3OXyGvyXuZFDzAP2iOFLEumymuYJITVpiH1IgsTEwTpdptDmZlMDMWeDmSawdaJIGCXQ=="], @@ -573,6 +592,8 @@ "tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="], + "tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="], + "trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="], "trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="], @@ -601,6 +622,8 @@ "use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="], + "use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="], + "vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="], "vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="], @@ -641,6 +664,8 @@ "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "cmdk/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "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-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="], + "next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="], } } diff --git a/frontend-nextjs/package.json b/frontend-nextjs/package.json index bd4ce5402..efb8bd653 100644 --- a/frontend-nextjs/package.json +++ b/frontend-nextjs/package.json @@ -8,6 +8,7 @@ "start": "next start" }, "dependencies": { + "@hello-pangea/dnd": "^18.0.1", "@lexical/react": "^0.39.0", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-collapsible": "^1.1.12", @@ -21,10 +22,12 @@ "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tooltip": "^1.2.8", + "@sapphire/snowflake": "^3.5.5", "@tanstack/react-query": "^5.90.13", "@tanstack/react-table": "^8.21.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "cmdk": "^1.1.1", "date-fns": "^4.1.0", "lexical": "^0.39.0", "lucide-react": "^0.562.0", diff --git a/frontend-nextjs/src/app/[lang]/dashboard/organisation/[orgId]/campaigns/[campaignId]/questions/campaign-questions.tsx b/frontend-nextjs/src/app/[lang]/dashboard/organisation/[orgId]/campaigns/[campaignId]/questions/campaign-questions.tsx new file mode 100644 index 000000000..e9cdfcd67 --- /dev/null +++ b/frontend-nextjs/src/app/[lang]/dashboard/organisation/[orgId]/campaigns/[campaignId]/questions/campaign-questions.tsx @@ -0,0 +1,557 @@ +"use client"; + +import { cn, dateToString } from "@/lib/utils"; +import { getCampaign, getCampaignRoles, RoleDetails } from "@/models/campaign"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { ArrowLeft, Check, ChevronsUpDown, GripVertical, Plus, Trash, X } from "lucide-react"; +import Link from "next/link"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" +import { createQuestion, deleteQuestion, getAllCommonQuestions, getAllRoleQuestions, MultiOptionQuestionOption, Question, QuestionType, updateQuestion } from "@/models/question"; +import { useEffect, useState } from "react"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { DragDropContext, Draggable, Droppable, DropResult } from '@hello-pangea/dnd'; +import { snowflakeGenerator } from "@/lib"; +import { Button } from "@/components/ui/button"; +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog" +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip" +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command" + +export default function CampaignQuestions({ campaignId, orgId, dict }: { campaignId: string, orgId: string, dict: any }) { + const queryClient = useQueryClient(); + + const { data: campaign } = useQuery({ + queryKey: [`${campaignId}-campaign-details`], + queryFn: () => getCampaign(campaignId), + }); + + const { data: roles } = useQuery({ + queryKey: [`${campaignId}-campaign-roles`], + queryFn: () => getCampaignRoles(campaignId), + }); + + const { data: commonQuestions } = useQuery({ + queryKey: [`${campaignId}-common-questions`], + queryFn: () => getAllCommonQuestions(campaignId), + }); + + const { data: rolesAndQuestions } = useQuery({ + queryKey: [`${campaignId}-all-role-questions`, roles], + queryFn: async () => { + if (!roles) return []; + return await Promise.all(roles.map(async (role) => { + const questions = await getAllRoleQuestions(campaignId, role.id); + return { role, questions }; + })); + } + }); + + const [allCommonQuestions, setAllCommonQuestions] = useState(commonQuestions ?? []); + const [allRoleQuestions, setAllRoleQuestions] = useState<{ role: RoleDetails, questions: Question[] }[]>(rolesAndQuestions ?? []); + + useEffect(() => { + if (commonQuestions) { + setAllCommonQuestions(commonQuestions); + } + if (rolesAndQuestions) { + setAllRoleQuestions(rolesAndQuestions); + } + }, [commonQuestions, rolesAndQuestions]); + + const [changedQuestions, setChangedQuestions] = useState([]); + const [newQuestions, setNewQuestions] = useState([]); + + const handleQuestionUpdate = async (action: "update" | "delete", question: Question) => { + if (action === 'delete') { + await deleteQuestion(campaignId, question.id); + await queryClient.invalidateQueries({ queryKey: [`${campaignId}-common-questions`] }); + await queryClient.invalidateQueries({ queryKey: [`${campaignId}-all-role-questions`] }); + return; + } + + + if (newQuestions.some((q) => q.id === question.id)) { + setNewQuestions(newQuestions.map((q) => q.id === question.id ? question : q)); + } else if (!changedQuestions.some((q) => q.id === question.id)) { + setChangedQuestions([...changedQuestions, question]); + } else { + setChangedQuestions(changedQuestions.map((q) => q.id === question.id ? question : q)); + } + + if (question.common) { + setAllCommonQuestions(allCommonQuestions.map((q) => q.id === question.id ? question : q)); + } else { + setAllRoleQuestions(allRoleQuestions.map(({ role, questions }) => { return { role, questions: questions.map((q) => q.id === question.id ? question : q) } })); + } + } + + const saveQuestions = async () => { + await Promise.all(changedQuestions.map(async (question) => { + await updateQuestion(campaignId, question.id, question); + })) + await Promise.all(newQuestions.map(async (question) => { + await createQuestion(campaignId, question); + })) + + setChangedQuestions([]); + setNewQuestions([]); + + await queryClient.invalidateQueries({ queryKey: [`${campaignId}-common-questions`] }); + await queryClient.invalidateQueries({ queryKey: [`${campaignId}-all-role-questions`] }) + } + + const addNewQuestion = (type: QuestionType, roleId: string) => { + const common = roleId === "common"; + + let newQuestion: Question = { id: snowflakeGenerator.generate().toString(), title: "", description: "", roles: [roleId], created_at: new Date().toISOString(), updated_at: new Date().toISOString(), question_type: type, data: { options: [] }, common, required: false }; + if (type === 'ShortAnswer') { + delete (newQuestion as any).data; + } + + if (common) { + newQuestion.roles = []; + setAllCommonQuestions([...allCommonQuestions, newQuestion]); + } else { + setAllRoleQuestions(allRoleQuestions.map(({ role, questions }) => { + if (role.id === roleId) { + return { role, questions: [...questions, newQuestion] }; + } + + return { role, questions }; + })); + } + + setNewQuestions([...newQuestions, newQuestion]); + } + + const addExistingQuestion = (questionId: string, oldRoleId: string, newRoleId: string) => { + // Common questions cannot be shared with roles + if (newRoleId === "common" || oldRoleId === "common") { return; } + + // Update all instances of the question with the new roleId + // setAllRoleQuestions(allRoleQuestions.map(({ role, questions }) => { + // const updatedQuestions = questions.map((question) => question.id === questionId ? { ...question, roles: [...question.roles, roleId] } : question); + // return {role, questions: updatedQuestions}; + // })); + const question = allRoleQuestions.find(({ role }) => role.id === oldRoleId)?.questions.find((question) => question.id === questionId); + if (!question) { return; } + + const updatedQuestion = { ...question, roles: [...question.roles, newRoleId] }; + const update = async () => { + await updateQuestion(campaignId, question.id, updatedQuestion); + await queryClient.invalidateQueries({ queryKey: [`${campaignId}-all-role-questions`] }); + } + + update(); + } + + return ( +
+
+
+ +
+ + {dict.common.back} +
+ +

{dict.dashboard.campaigns.campaign_questions}

+

{campaign?.name}

+
+
+
+ +
+ + Common + {roles?.map((role) => ( + {role.name} + ))} + + +
+ + + addNewQuestion(type, "common")} onAddExisting={(questionId) => { }} disableExisting={true} dict={dict} /> + + {allRoleQuestions?.map(({ role, questions }) => ( + + + addNewQuestion(type, role.id)} onAddExisting={(questionId, oldRoleId) => addExistingQuestion(questionId, oldRoleId, role.id)} dict={dict} /> + + ))} +
+
+
+ ); +} + +function NewQuestionButton({ currentRole, allRoleQuestions, onAddNew, onAddExisting, disableExisting = false, dict }: { currentRole: string, allRoleQuestions: { role: RoleDetails, questions: Question[] }[], onAddNew: (type: QuestionType) => void, onAddExisting: (questionId: string, oldRoleId: string) => void, disableExisting?: boolean, dict: any }) { + const [questionId, setQuestionId] = useState(""); + const [oldRoleId, setOldRoleId] = useState(""); + + return ( +
+ + + + + + + onAddNew("ShortAnswer")}>{dict.common.question_types.short_answer} + onAddNew("MultiChoice")}>{dict.common.question_types.multi_choice} + onAddNew("MultiSelect")}>{dict.common.question_types.multi_select} + onAddNew("DropDown")}>{dict.common.question_types.dropdown} + onAddNew("Ranking")}>{dict.common.question_types.ranking} + {!disableExisting && ( + <> + + + {dict.common.question_types.existing_questions} + + + )} + + + + + + {dict.common.question_types.existing_questions} + +
+ role.id !== currentRole) + .map(({ role, questions }) => ( + { + role, + questions: questions.filter((question) => !question.roles.includes(currentRole)) + } + )) + .filter(({ questions }) => questions.length > 0) + } setQuestion={setQuestionId} setOldRoleId={setOldRoleId} /> +
+ + + + + + + + +
+
+
+ ); +} + + +function ExistingQuestionsCombobox({ allRoleQuestions, setQuestion, setOldRoleId }: { allRoleQuestions: { role: RoleDetails, questions: Question[] }[], setQuestion: (questionId: string) => void, setOldRoleId: (oldRoleId: string) => void }) { + const [open, setOpen] = useState(false) + const [value, setValue] = useState("") + + const handleSetValue = (value: string, oldRoleId: string) => { + setValue(value); + setQuestion(value); + setOldRoleId(oldRoleId); + } + + return ( + + + + + + + + + No question found. + {allRoleQuestions.map(({ role, questions }) => ( + + {questions.map((question) => ( + { + handleSetValue(currentValue === questions.find((question) => question.id === value)?.title ? "" : question.id, role.id) + setOpen(false) + }} + > + {question.title} + + + ))} + + ))} + + + + + ) +} + +function QuestionEditor({ possibleRole, questions, handleQuestionUpdate, dict }: { campaignId: string, possibleRole?: RoleDetails, questions?: Question[], handleQuestionUpdate: (action: "update" | "delete", question: Question) => Promise, dict: any }) { + const roleId = possibleRole?.id ?? "common"; + + return ( +
+ {questions?.map((question) => { + if (question.question_type !== 'ShortAnswer') { + return + } + return ; + })} +
+ ); +} + +function MultiOptionQuestionCard({ question, currentRole, possibleRole, handleQuestionUpdate, dict }: { question?: Question, currentRole: string, possibleRole?: RoleDetails, handleQuestionUpdate: (action: "update" | "delete", question: Question) => Promise, dict: any }) { + const [title, setTitle] = useState(question?.title ?? ""); + const [questionType, setQuestionType] = useState(question?.question_type ?? ""); + const [options, setOptions] = useState(question?.data?.options ?? []); + + const handleDragEnd = async (result: DropResult) => { + if (!result.destination) { + return; + } + + const items = Array.from(options); + const [reorderedItem] = items.splice(result.source.index, 1); + items.splice(result.destination.index, 0, reorderedItem); + const newItems = items.map((option, index) => ({ ...option, display_order: index + 1 })); + + setOptions(newItems); + await handleQuestionUpdate('update', { ...question!, data: { options: newItems } }); + } + + const addOption = async (text: string) => { + // Generate random id for use with DnD and to send to server (which expects i64 - as string or number) + const newItems: MultiOptionQuestionOption[] = [...options, { id: snowflakeGenerator.generate().toString(), text: text, display_order: options.length + 1 }]; + setOptions(newItems); + await handleQuestionUpdate('update', { ...question!, data: { options: newItems } }); + } + + const removeOption = async (id: string) => { + const newItems = options.filter((option) => option.id !== id); + setOptions(newItems); + await handleQuestionUpdate('update', { ...question!, data: { options: newItems } }); + } + + const updateOption = async (id: string, text: string) => { + const newItems = options.map((option) => option.id === id ? { ...option, text: text } : option); + setOptions(newItems); + await handleQuestionUpdate('update', { ...question!, data: { options: newItems } }); + } + + const updateTitle = async (title: string) => { + setTitle(title); + await handleQuestionUpdate('update', { ...question!, title: title }); + } + + const updateQuestionType = async (questionType: string) => { + setQuestionType(questionType); + await handleQuestionUpdate('update', { ...question!, question_type: questionType as QuestionType }); + } + + const handleDeleteQuestion = async () => { + await handleQuestionUpdate('delete', question!); + } + + const handleRemoveQuestionFromRole = async () => { + const updatedQuestion = { ...question!, roles: question?.roles?.filter((role) => role !== currentRole) ?? [] }; + await handleQuestionUpdate('update', updatedQuestion); + } + + return ( +
+
+
+ await updateTitle(e.target.value)} /> +
+ { + question?.roles && question?.roles.length > 1 && ( + + + + + +

Remove question from this role

+
+
+ ) + } + +
+
+ +
+
+ + + {(provided) => ( +
+ {options.map((option, index) => ( + + {(provided) => ( +
+
+ +
+ +
+ await updateOption(option.id, (e.target as HTMLInputElement).value)} /> +
+ await removeOption(option.id)} /> +
+ )} +
+ ))} + {provided.placeholder} +
+ )} +
+
+
+ +
+ +
+
+ { + if (e.key === 'Enter') { + await addOption((e.target as HTMLInputElement).value); + (e.target as HTMLInputElement).value = ''; + } + }} /> +

{dict.dashboard.campaigns.questions.option_help}

+
+
+
+
+ ); +} + +function OptionDecorator({ questionType, index }: { questionType: string, index: number }) { + if (questionType === 'MultiChoice') { + return
; + } else if (questionType === 'MultiSelect') { + return
; + } else if (questionType === 'DropDown') { + return
; + } else if (questionType === 'Ranking') { + return
; + } + return
; +} + +function ShortAnswerQuestionCard({ question, currentRole, possibleRole, handleQuestionUpdate, dict }: { question?: Question, currentRole: string, possibleRole?: RoleDetails, handleQuestionUpdate: (action: "update" | "delete", question: Question) => Promise, dict: any }) { + const [title, setTitle] = useState(question?.title ?? ""); + + const updateTitle = async (title: string) => { + setTitle(title); + await handleQuestionUpdate('update', { ...question!, title: title }); + } + + const handleDeleteQuestion = async () => { + await handleQuestionUpdate('delete', question!); + } + + const handleRemoveQuestionFromRole = async () => { + const updatedQuestion = { ...question!, roles: question?.roles?.filter((role) => role !== currentRole) ?? [] }; + await handleQuestionUpdate('update', updatedQuestion); + } + + return ( +
+
+ await updateTitle(e.target.value)} /> +
+ { + question?.roles && question?.roles.length > 1 && ( + + + + + +

Remove question from this role

+
+
+ ) + } + +
+
+
+
+

{dict.dashboard.campaigns.questions.answer_text}

+
+
+
+ ); +} \ No newline at end of file diff --git a/frontend-nextjs/src/app/[lang]/dashboard/organisation/[orgId]/campaigns/[campaignId]/questions/page.tsx b/frontend-nextjs/src/app/[lang]/dashboard/organisation/[orgId]/campaigns/[campaignId]/questions/page.tsx new file mode 100644 index 000000000..0a2b7edee --- /dev/null +++ b/frontend-nextjs/src/app/[lang]/dashboard/organisation/[orgId]/campaigns/[campaignId]/questions/page.tsx @@ -0,0 +1,47 @@ +import { dehydrate, HydrationBoundary, QueryClient, useQuery } from "@tanstack/react-query"; +import { getDictionary } from "@/app/[lang]/dictionaries"; +import { getApplicationRatingsSummary } from "@/models/application"; +import { getCampaign, getCampaignRoles } from "@/models/campaign"; +import CampaignQuestions from "./campaign-questions"; +import { getAllCommonQuestions, getAllRoleQuestions } from "@/models/question"; + +export default async function ApplicationAvgRatingsPage({ params }: { params: Promise<{ campaignId: string, orgId: string, lang: string }>; }) { + const { lang, campaignId, orgId } = await params; + const dict = await getDictionary(lang); + const queryClient = new QueryClient(); + + await queryClient.prefetchQuery({ + queryKey: [`${campaignId}-campaign-details`], + queryFn: () => getCampaign(campaignId), + }); + + await queryClient.prefetchQuery({ + queryKey: [`${campaignId}-campaign-roles`], + queryFn: () => getCampaignRoles(campaignId), + }); + + const roles = await getCampaignRoles(campaignId); + + await queryClient.prefetchQuery({ + queryKey: [`${campaignId}-common-questions`], + queryFn: () => getAllCommonQuestions(campaignId), + }); + + + await queryClient.prefetchQuery({ + queryKey: [`${campaignId}-all-role-questions`, roles], + queryFn: async () => { + if (!roles) return []; + return await Promise.all(roles.map(async (role) => { + const questions = await getAllRoleQuestions(campaignId, role.id); + return { role, questions }; + })); + } + }); + + return ( + + + + ); +} \ No newline at end of file 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 5c89764ca..0f4077f5c 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 @@ -28,7 +28,7 @@ export default function ApplicationDetailsComponent({ applicationId, campaignId, }); const { data: commonQuestions } = useQuery({ - queryKey: [`${applicationId}-common-questions`], + queryKey: [`${campaignId}-common-questions`], queryFn: () => getAllCommonQuestions(campaignId), }); @@ -43,7 +43,7 @@ export default function ApplicationDetailsComponent({ applicationId, campaignId, const roleQuestionsQueries = useQueries({ queries: roles.map((role) => ({ - queryKey: [`${applicationId}-role-questions-${role.campaign_role_id}`], + queryKey: [`${campaignId}-role-questions-${role.campaign_role_id}`], queryFn: () => getAllRoleQuestions(campaignId, role.campaign_role_id), })) }); diff --git a/frontend-nextjs/src/components/ui/command.tsx b/frontend-nextjs/src/components/ui/command.tsx new file mode 100644 index 000000000..8cb4ca7a5 --- /dev/null +++ b/frontend-nextjs/src/components/ui/command.tsx @@ -0,0 +1,184 @@ +"use client" + +import * as React from "react" +import { Command as CommandPrimitive } from "cmdk" +import { SearchIcon } from "lucide-react" + +import { cn } from "@/lib/utils" +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" + +function Command({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function CommandDialog({ + title = "Command Palette", + description = "Search for a command to run...", + children, + className, + showCloseButton = true, + ...props +}: React.ComponentProps & { + title?: string + description?: string + className?: string + showCloseButton?: boolean +}) { + return ( + + + {title} + {description} + + + + {children} + + + + ) +} + +function CommandInput({ + className, + ...props +}: React.ComponentProps) { + return ( +
+ + +
+ ) +} + +function CommandList({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function CommandEmpty({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function CommandGroup({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function CommandSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function CommandItem({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function CommandShortcut({ + className, + ...props +}: React.ComponentProps<"span">) { + return ( + + ) +} + +export { + Command, + CommandDialog, + CommandInput, + CommandList, + CommandEmpty, + CommandGroup, + CommandItem, + CommandShortcut, + CommandSeparator, +} diff --git a/frontend-nextjs/src/dictionaries/en.json b/frontend-nextjs/src/dictionaries/en.json index c4e5baa56..d1187a19c 100644 --- a/frontend-nextjs/src/dictionaries/en.json +++ b/frontend-nextjs/src/dictionaries/en.json @@ -14,6 +14,17 @@ "description": "Description", "about": "About", "questions": "Questions", + "title": "Title", + "question_type": "Question Type", + "question_types": { + "short_answer": "Short answer", + "multi_choice": "Multichoice", + "multi_select": "Multiselect", + "dropdown": "Dropdown", + "ranking": "Ranking", + "existing_questions": "Existing Questions" + }, + "options": "Options", "common_questions": "Common Questions", "back": "Back", "application": "Application", @@ -71,6 +82,16 @@ "publish": "Publish", "published": "Published", "draft": "Draft", + "campaign_questions": "Campaign Questions", + "questions": { + "answer_text": "Answer Text", + "add_option": "Add Option", + "option_help": "Press Enter to add option", + "question_updated": "Question updated", + "question_deleted": "Question deleted", + "update_success": "The question has been updated successfully.", + "delete_success": "The question has been deleted successfully." + }, "roles": { "number_of_positions": "Number of Positions", "add_role": "Add Role" @@ -116,7 +137,8 @@ "save": "Save", "destructive_confirm": "Are you absolutely sure?", "confirm_understand": "Ok", - "invite": "Invite" + "invite": "Invite", + "add": "Add" } } } diff --git a/frontend-nextjs/src/dictionaries/zh.json b/frontend-nextjs/src/dictionaries/zh.json index f87ee454c..ed7c0790c 100644 --- a/frontend-nextjs/src/dictionaries/zh.json +++ b/frontend-nextjs/src/dictionaries/zh.json @@ -14,6 +14,17 @@ "description": "详细描述", "about": "关于", "questions": "问题", + "title": "标题", + "question_type": "问题类型", + "question_types": { + "short_answer": "短答案", + "multi_choice": "多选题", + "multi_select": "多选题", + "dropdown": "下拉题", + "ranking": "排序题", + "existing_questions": "现有问题列表" + }, + "options": "选项", "common_questions": "通用问题", "back": "返回", "application": "申请", @@ -71,6 +82,16 @@ "publish": "发布", "published": "已发布", "draft": "草稿", + "campaign_questions": "活动问题", + "questions": { + "answer_text": "答案文本", + "add_option": "添加选项", + "option_help": "按 Enter 添加选项", + "question_updated": "问题已更新", + "question_deleted": "问题已删除", + "update_success": "问题已更新成功。", + "delete_success": "问题已删除成功。" + }, "roles": { "number_of_positions": "职位数量", "add_role": "添加角色" @@ -117,7 +138,8 @@ "save": "保存", "destructive_confirm": "你确定吗", "confirm_understand": "确定", - "invite": "邀请" + "invite": "邀请", + "add": "添加" } } } diff --git a/frontend-nextjs/src/lib/id.ts b/frontend-nextjs/src/lib/id.ts new file mode 100644 index 000000000..0fded8055 --- /dev/null +++ b/frontend-nextjs/src/lib/id.ts @@ -0,0 +1,4 @@ +import { Snowflake } from "@sapphire/snowflake"; + +const UNIX_EPOCH = new Date('1970-01-01T00:00:00.000Z'); +export const snowflakeGenerator = new Snowflake(UNIX_EPOCH); \ No newline at end of file diff --git a/frontend-nextjs/src/lib/index.ts b/frontend-nextjs/src/lib/index.ts index 61db02c45..ac43c696a 100644 --- a/frontend-nextjs/src/lib/index.ts +++ b/frontend-nextjs/src/lib/index.ts @@ -5,3 +5,7 @@ export { // API client export { apiRequest, ApiError } from "./api"; + + +// Snowflake generator +export { snowflakeGenerator } from "./id"; \ No newline at end of file diff --git a/frontend-nextjs/src/models/question.ts b/frontend-nextjs/src/models/question.ts index c2af5a54e..aafcfbce3 100644 --- a/frontend-nextjs/src/models/question.ts +++ b/frontend-nextjs/src/models/question.ts @@ -36,6 +36,26 @@ export async function getAllRoleQuestions(campaignId: string, roleId: string): P return await apiRequest(`/api/v1/campaign/${campaignId}/role/${roleId}/questions`); } +export async function createQuestion(campaignId: string, question: Question): Promise<{ id: string }> { + return await apiRequest<{ id: string }>(`/api/v1/campaign/${campaignId}/question`, { + method: "POST", + body: question, + }); +} + +export async function updateQuestion(campaignId: string, questionId: string, question: Question): Promise { + await apiRequest(`/api/v1/campaign/${campaignId}/question/${questionId}`, { + method: "PATCH", + body: question, + }); +} + +export async function deleteQuestion(campaignId: string, questionId: string): Promise { + await apiRequest(`/api/v1/campaign/${campaignId}/question/${questionId}`, { + method: "DELETE", + }); +} + export function linkQuestionsAndAnswers(questions: Question[], answers: Answer[]) { return questions.map((question) => { const answer = answers?.find((answer) => answer.question_id === question.id);