diff --git a/.gitignore b/.gitignore index cc20e31..3887faf 100644 --- a/.gitignore +++ b/.gitignore @@ -22,4 +22,4 @@ dist/ # environment variables .env dfx.json -.astro \ No newline at end of file +.astro/ \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index b84da17..b95876e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -22,6 +22,7 @@ dependencies = [ "futures", "hex", "ic-cdk", + "ic-management-canister-types", "ic-stable-structures", "minicbor 0.26.4", "minicbor-derive 0.16.2", @@ -40,12 +41,14 @@ dependencies = [ "hex", "ic-cdk", "ic-ledger-types", + "ic-management-canister-types", "ic-stable-structures", "icrc-ledger-types", "minicbor 0.26.4", "minicbor-derive 0.16.2", "num-bigint", "serde", + "serde_json", "sha2", "shared", "thiserror 2.0.12", @@ -707,6 +710,12 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eded382c5f5f786b989652c49544c4877d9f015cc22e145a5ea8ea66c2921cd2" +[[package]] +name = "ryu" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" + [[package]] name = "serde" version = "1.0.219" @@ -736,6 +745,18 @@ dependencies = [ "syn 2.0.100", ] +[[package]] +name = "serde_json" +version = "1.0.140" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", +] + [[package]] name = "sha2" version = "0.10.9" diff --git a/Cargo.toml b/Cargo.toml index 9fcf14b..61fb465 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,9 @@ resolver = "2" [workspace.dependencies] candid = "0.10" ic-cdk = "0.18.5" +ic-management-canister-types = "0.3.1" serde = "1.0.219" +serde_json = "1.0.140" hex = "0.4.3" ic-stable-structures = "0.6.8" minicbor = { version = "0.26.4", features = ["alloc", "derive"] } diff --git a/package-lock.json b/package-lock.json index 2c907ef..c23467c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,10 @@ "workspaces": [ "src/atlas_frontend" ], + "dependencies": { + "@astrojs/tailwind": "^6.0.2", + "axios": "^1.11.0" + }, "devDependencies": { "eslint": "^9.24.0", "eslint-config-prettier": "^10.1.1", @@ -2583,6 +2587,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">=12" } @@ -2599,6 +2604,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">=12" } @@ -2615,6 +2621,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">=12" } @@ -2631,6 +2638,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">=12" } @@ -2647,6 +2655,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">=12" } @@ -2663,6 +2672,7 @@ "os": [ "freebsd" ], + "peer": true, "engines": { "node": ">=12" } @@ -2679,6 +2689,7 @@ "os": [ "freebsd" ], + "peer": true, "engines": { "node": ">=12" } @@ -2695,6 +2706,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=12" } @@ -2711,6 +2723,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=12" } @@ -2727,6 +2740,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=12" } @@ -2743,6 +2757,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=12" } @@ -2759,6 +2774,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=12" } @@ -2775,6 +2791,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=12" } @@ -2791,6 +2808,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=12" } @@ -2807,6 +2825,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=12" } @@ -2823,6 +2842,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=12" } @@ -2855,6 +2875,7 @@ "os": [ "netbsd" ], + "peer": true, "engines": { "node": ">=12" } @@ -2887,6 +2908,7 @@ "os": [ "openbsd" ], + "peer": true, "engines": { "node": ">=12" } @@ -2903,6 +2925,7 @@ "os": [ "sunos" ], + "peer": true, "engines": { "node": ">=12" } @@ -2919,6 +2942,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">=12" } @@ -2935,6 +2959,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">=12" } @@ -2951,6 +2976,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">=12" } @@ -4062,6 +4088,7 @@ "hasInstallScript": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "detect-libc": "^1.0.3", "is-glob": "^4.0.3", @@ -4103,6 +4130,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -4123,6 +4151,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -4143,6 +4172,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -4163,6 +4193,7 @@ "os": [ "freebsd" ], + "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -4183,6 +4214,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -4203,6 +4235,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -4223,6 +4256,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -4243,6 +4277,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -4263,6 +4298,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -4283,6 +4319,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -4303,6 +4340,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -4323,6 +4361,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -4343,6 +4382,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -7518,13 +7558,13 @@ } }, "node_modules/axios": { - "version": "1.8.4", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.8.4.tgz", - "integrity": "sha512-eBSYY4Y68NNlHbHBMdeDmKNtDgXWhQsJcGqzO3iLUM0GraQFSS9cVgPX5I9b3lbdFKyYoAEGAZF1DwhTaljNAw==", + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.11.0.tgz", + "integrity": "sha512-1Lx3WLFQWm3ooKDYZD1eXmoGO9fxYQjrycfHFC8P0sCfQVXyROp0p9PFWBehewBOdCwHc+f/b8I0fMto5eSfwA==", "license": "MIT", "dependencies": { "follow-redirects": "^1.15.6", - "form-data": "^4.0.0", + "form-data": "^4.0.4", "proxy-from-env": "^1.1.0" } }, @@ -8575,6 +8615,7 @@ "integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==", "license": "Apache-2.0", "optional": true, + "peer": true, "bin": { "detect-libc": "bin/detect-libc.js" }, @@ -9751,14 +9792,15 @@ } }, "node_modules/form-data": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.2.tgz", - "integrity": "sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", + "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", "mime-types": "^2.1.12" }, "engines": { @@ -12542,7 +12584,8 @@ "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/node-releases": { "version": "2.0.19", diff --git a/package.json b/package.json index 9f51157..35f5d0a 100644 --- a/package.json +++ b/package.json @@ -25,5 +25,9 @@ "eslint-plugin-react": "^7.37.5", "husky": "^9.1.7", "typescript-eslint": "^8.29.0" + }, + "dependencies": { + "@astrojs/tailwind": "^6.0.2", + "axios": "^1.11.0" } } diff --git a/src/atlas_frontend/.astro/types.d.ts b/src/atlas_frontend/.astro/types.d.ts deleted file mode 100644 index f964fe0..0000000 --- a/src/atlas_frontend/.astro/types.d.ts +++ /dev/null @@ -1 +0,0 @@ -/// diff --git a/src/atlas_frontend/src/canisters/atlasSpace/api.ts b/src/atlas_frontend/src/canisters/atlasSpace/api.ts index bc4c790..715831f 100644 --- a/src/atlas_frontend/src/canisters/atlasSpace/api.ts +++ b/src/atlas_frontend/src/canisters/atlasSpace/api.ts @@ -15,13 +15,14 @@ import type { Dispatch } from "react"; import type { UnknownAction } from "@reduxjs/toolkit"; import type { Principal } from "@dfinity/principal"; import type { ExternalLinks } from "./types.js"; +import { getUserGuilds } from "../../components/Integrations/discord/userGuilds.js"; +import { validateDiscordInvite as validateInvite } from "../../components/Integrations/discord/inviteLink.js"; +import type { DiscordGuild, DiscordInviteApiResponse } from "../../components/Integrations/discord/types.js"; +import type { DiscordTaskContent, GenericTaskContent } from "../../utils/taskMapper.js"; + -interface CreateSubtaskArg { - task_type: string; - title: string; - description: string; - allow_resubmit: boolean; -} + +type CreateSubtaskArg = GenericTaskContent | DiscordTaskContent; interface GetAtlasSpaceArgs { unAuthAtlasSpace: ActorSubclass<_SERVICE>; @@ -83,13 +84,27 @@ export const createNewTask = async ({ tasks, taskTitle, }: CreateNewSpaceTaskArgs) => { - const transformedTasks: TaskContent[] = tasks.map((arg) => ({ - TitleAndDescription: { - task_title: arg.title, - task_description: arg.description, - allow_resubmit: arg.allow_resubmit, - }, - })); + const transformedTasks: TaskContent[] = tasks.map((arg) => { + if (arg.task_type === "discord") { + return { + DiscordTask: { + task_title: arg.title, + task_description: arg.description, + guild_id: arg.guild_id, + invite_link: arg.invite_link, + allow_resubmit: arg.allow_resubmit, + }, + }; + } else { + return { + TitleAndDescription: { + task_title: arg.title, + task_description: arg.description, + allow_resubmit: arg.allow_resubmit, + }, + }; + } + }); const call = authAtlasSpaceActor.create_task({ task_title: taskTitle, @@ -301,3 +316,16 @@ export const editSpace = async ({ errMsg: "Failed to edit space", }); }; + +export const getDiscordGuilds = async ( + accessToken: string +): Promise => { + return await getUserGuilds(accessToken); +}; + +export const validateDiscordInvite = async ( + inviteCode: string, + expectedGuildId: string +): Promise => { + return await validateInvite(inviteCode, expectedGuildId); +}; \ No newline at end of file diff --git a/src/atlas_frontend/src/canisters/atlasSpace/tasks.ts b/src/atlas_frontend/src/canisters/atlasSpace/tasks.ts index ae0e7f5..b43358a 100644 --- a/src/atlas_frontend/src/canisters/atlasSpace/tasks.ts +++ b/src/atlas_frontend/src/canisters/atlasSpace/tasks.ts @@ -1,32 +1,31 @@ +import type { Principal } from "@dfinity/principal"; import type { + SubmissionData, SubmissionState, TaskType, } from "../../../../declarations/atlas_space/atlas_space.did"; import type { UserSubmissionsData } from "./types"; -export const getUsersSubmissions = (tasks: TaskType[]) => { - const data = tasks.reduce((acc, task, index) => { - if ("GenericTask" in task) { - const genericTask = task.GenericTask; - genericTask.submission.forEach(([principal, submissionData]) => { - const principalText = principal.toText(); - if (!acc[principalText]) { - acc[principalText] = {}; - } - if (!acc[principalText][`${index}`]) { - acc[principalText][`${index}`] = { - submissionData, - taskType: "GenericTask", - }; - } - }); - return acc; - } +export const getUsersSubmissions = (tasks: { [key: string]: TaskType }) => { + const data = Object.entries(tasks).reduce((acc, [subtaskIdStr, task]) => { + const foundType = Object.keys(task)[0] as keyof TaskType; + const taskData = task[foundType] as { submission: [Principal, SubmissionData][] }; + + taskData.submission.forEach(([principal, submissionData]) => { + const principalText = principal.toText(); + if (!acc[principalText]) { + acc[principalText] = {}; + } + acc[principalText][subtaskIdStr] = { + submissionData, + taskType: foundType, + }; + }); + return acc; }, {} as UserSubmissionsData); - return new UserSubmissions(data); -}; +} export class UserSubmissions { constructor(public userSubmissionsData: UserSubmissionsData) {} diff --git a/src/atlas_frontend/src/components/Integrations/DiscordCallback.tsx b/src/atlas_frontend/src/components/Integrations/DiscordCallback.tsx index e37ea36..e914795 100644 --- a/src/atlas_frontend/src/components/Integrations/DiscordCallback.tsx +++ b/src/atlas_frontend/src/components/Integrations/DiscordCallback.tsx @@ -1,21 +1,13 @@ import React, { useEffect } from "react"; -import { useAuth } from "@nfid/identitykit/react"; const DiscordCallback = () => { - const { user } = useAuth(); useEffect(() => { const query = new URLSearchParams(window.location.hash.substring(1)); - const tokenType = query.get("token_type"); const accessToken = query.get("access_token"); - const state = query.get("state"); - const expiresIn = query.get("expires_in"); if ( - !tokenType || !accessToken || - !expiresIn || - state === user?.principal.toString() || !window.opener ) { return @@ -23,7 +15,7 @@ const DiscordCallback = () => { try { window.opener.postMessage( - { tokenType, accessToken, state, expiresIn }, + {accessToken}, window.location.origin ); } catch (err) { diff --git a/src/atlas_frontend/src/integrations/discord.ts b/src/atlas_frontend/src/components/Integrations/discord/discord.ts similarity index 91% rename from src/atlas_frontend/src/integrations/discord.ts rename to src/atlas_frontend/src/components/Integrations/discord/discord.ts index 9e2ab32..b60462c 100644 --- a/src/atlas_frontend/src/integrations/discord.ts +++ b/src/atlas_frontend/src/components/Integrations/discord/discord.ts @@ -1,5 +1,5 @@ import axios from "axios"; -import { DISCORD_CALLBACK_PATH } from "../router/paths"; +import { DISCORD_CALLBACK_PATH } from "../../../router/paths"; export interface UserData { accent_color: null | string; @@ -33,7 +33,7 @@ export const getOAuth2URL = (stateData?: string) => { new URL(DISCORD_CALLBACK_PATH, window.location.origin).toString() ); url.searchParams.set("response_type", "token"); - url.searchParams.set("scope", "identify"); + url.searchParams.set("scope", "identify guilds"); if (stateData) url.searchParams.set("state", stateData); return url.toString(); diff --git a/src/atlas_frontend/src/components/Integrations/discord/inviteLink.ts b/src/atlas_frontend/src/components/Integrations/discord/inviteLink.ts new file mode 100644 index 0000000..4799ac9 --- /dev/null +++ b/src/atlas_frontend/src/components/Integrations/discord/inviteLink.ts @@ -0,0 +1,37 @@ +import type { DiscordInviteApiResponse } from "./types"; +import axios, { AxiosError } from "axios"; + +export const validateDiscordInvite = async ( + inviteCode: string, + expectedGuildId: string +): Promise => { + if (!inviteCode || inviteCode.includes("/")) { + throw new Error("Invalid Discord invite code format."); + } + + const url = `https://discord.com/api/v10/invites/${inviteCode}?with_counts=false`; + + let response; + try { + response = await axios.get(url); + } catch (err: unknown) { + throw new Error( + `The invite link is invalid.` + ); + + } + + const inviteData: DiscordInviteApiResponse = response.data; + + const guild = inviteData.guild; + + if (guild && guild.id === expectedGuildId) { + return inviteData; + } else if (guild) { + throw new Error( + `Invite is for a different server` + ); + } else { + throw new Error("Invite is not for a valid server."); + } +}; \ No newline at end of file diff --git a/src/atlas_frontend/src/components/Integrations/discord/types.ts b/src/atlas_frontend/src/components/Integrations/discord/types.ts new file mode 100644 index 0000000..8201193 --- /dev/null +++ b/src/atlas_frontend/src/components/Integrations/discord/types.ts @@ -0,0 +1,17 @@ +export interface DiscordGuild { + id: string; + name: string; + icon: string | null; + owner: boolean; + permissions: string; +} + +export interface DiscordGuildInfo { + id: string; + name: string; +} + +export interface DiscordInviteApiResponse { + guild: DiscordGuildInfo | null; + expires_at: string | null; +} \ No newline at end of file diff --git a/src/atlas_frontend/src/components/Integrations/discord/userGuilds.ts b/src/atlas_frontend/src/components/Integrations/discord/userGuilds.ts new file mode 100644 index 0000000..e7968ed --- /dev/null +++ b/src/atlas_frontend/src/components/Integrations/discord/userGuilds.ts @@ -0,0 +1,19 @@ +import type { DiscordGuild } from "./types"; + +export const getUserGuilds = async (token: string): Promise => { + const url = "https://discord.com/api/users/@me/guilds"; + + const response = await fetch(url, { + headers: { + Authorization: `Bearer ${token.trim()}`, + "Content-Type": "application/json", + }, + }); + + if (!response.ok) { + throw new Error(`Failed to fetch user guilds: ${response.statusText}`); + } + + const guilds: DiscordGuild[] = await response.json(); + return guilds; +}; \ No newline at end of file diff --git a/src/atlas_frontend/src/components/Shared/Button.tsx b/src/atlas_frontend/src/components/Shared/Button.tsx index 6c537cf..7f3ff9c 100644 --- a/src/atlas_frontend/src/components/Shared/Button.tsx +++ b/src/atlas_frontend/src/components/Shared/Button.tsx @@ -8,17 +8,20 @@ interface ButtonProps { arrow?: boolean; smallText?: string light?: boolean + disabled?: boolean; } -const Button = ({children, onClick, className, light}: ButtonProps) => { +const Button = ({children, onClick, className, light, disabled}: ButtonProps) => { return ( - - - + + + ); }; diff --git a/src/atlas_frontend/src/components/Space/TaskCard/index.tsx b/src/atlas_frontend/src/components/Space/TaskCard/index.tsx index 745f8e1..d754bea 100644 --- a/src/atlas_frontend/src/components/Space/TaskCard/index.tsx +++ b/src/atlas_frontend/src/components/Space/TaskCard/index.tsx @@ -6,6 +6,7 @@ import { DECIMALS } from "../../../canisters/ckUsdcLedger/constans.ts"; import type { Principal } from "@dfinity/principal"; import { getTaskPath } from "../../../router/paths.ts"; import type { Task } from "../../../../../declarations/atlas_space/atlas_space.did"; +import { BlockchainTask } from "../../../utils/tasks.ts"; interface TaskCardProps { type: "ongoing" | "starting" | "expired"; @@ -15,14 +16,16 @@ interface TaskCardProps { spaceId: Principal } -const TaskCard = ({ startingIn, task, id, type, spaceId}: TaskCardProps) => { +const TaskCard = ({ startingIn, task, id, type, spaceId }: TaskCardProps) => { const navigate = useNavigate(); - const reward = formatUnits(task.token_reward.CkUsdc.amount, DECIMALS) - const lastTask = task.tasks.at(-1)?.GenericTask.submission.filter(([, submission]) => 'Accepted' in submission.state) + const reward = formatUnits(task.token_reward.CkUsdc.amount, DECIMALS); + + const taskWrapper = new BlockchainTask(task); + const acceptedCount = taskWrapper.getAcceptedSubmissions(); return ( -
+ @@ -51,3 +56,4 @@ const TaskCard = ({ startingIn, task, id, type, spaceId}: TaskCardProps) => { }; export default TaskCard; + diff --git a/src/atlas_frontend/src/components/Submissions/TaskSummation.tsx b/src/atlas_frontend/src/components/Submissions/TaskSummation.tsx new file mode 100644 index 0000000..42ba209 --- /dev/null +++ b/src/atlas_frontend/src/components/Submissions/TaskSummation.tsx @@ -0,0 +1,237 @@ +import React, { useState } from "react"; +import { useDispatch, useSelector } from "react-redux"; +import type { ActorSubclass } from "@dfinity/agent"; +import { Principal } from "@dfinity/principal"; +import { + acceptSubtaskSubmission, + getSpaceTasks, + rejectSubtaskSubmission, +} from "../../canisters/atlasSpace/api"; +import type { + _SERVICE, + TaskType, +} from "../../../../declarations/atlas_space/atlas_space.did"; +import type { UserSubmissions } from "../../canisters/atlasSpace/tasks"; +import Button from "../Shared/Button"; +import type { TaskData } from "../../canisters/atlasSpace/types"; +import toast from "react-hot-toast"; +import type { RootState } from "../../store/store"; +import { useForm, type SubmitHandler } from "react-hook-form"; +import { runWithLoading } from "../../utils/loading"; + +type GenericTaskType = Extract['GenericTask']; +type DiscordTaskType = Extract['DiscordTask']; + +export interface TaskSummationProps { + task: GenericTaskType | DiscordTaskType; + usersSubmissions: UserSubmissions; + submission: TaskData; + authAtlasSpace: ActorSubclass<_SERVICE>; + taskId: string; + subtaskId: string; + unAuthAtlasSpace: ActorSubclass<_SERVICE>; + spaceId: string; + submissionState: "Rejected" | "WaitingForReview" | "Accepted"; + user: string; +} + +interface SubtaskSubmission { + authAtlasSpace: ActorSubclass<_SERVICE>; + userPrincipal: Principal; + taskId: bigint; + subtaskId: bigint; + reason: string | null; +} + +const getDiscordAccountCreationDate = (userId: string): Date => { + const discordEpoch = 1420070400000; + const timestamp = (BigInt(userId) >> 22n) + BigInt(discordEpoch); + return new Date(Number(timestamp)); +}; + +const TaskSummation = ({ + task, + submission, + authAtlasSpace, + taskId, + subtaskId, + unAuthAtlasSpace, + spaceId, + submissionState, + user, +}: TaskSummationProps) => { + const dispatch = useDispatch(); + const userPrincipal = Principal.from(user); + const isLoading = useSelector((state: RootState) => state.app.isLoading); + const [showRejectPopup, setShowRejectPopup] = useState(false); + + const { register, handleSubmit } = useForm(); + const onSubmit: SubmitHandler = async (data) => { + const rawReason = data.reason?.trim(); + const trimmedRawReason = !rawReason || rawReason === "" ? null : rawReason; + + await runWithLoading(async () => { + await toast.promise( + rejectSubtaskSubmission({ + authAtlasSpace, + userPrincipal, + taskId: BigInt(taskId), + subtaskId: BigInt(subtaskId), + reason: trimmedRawReason, + }), + { + loading: "Rejecting task...", + error: "Failed to reject task.", + } + ); + setShowRejectPopup(false); + await getSpaceTasks({ + spaceId, + unAuthAtlasSpace, + dispatch, + }); + }, dispatch, + () => setShowRejectPopup(false) + ); + }; + + const acceptSubtask = async () => { + await runWithLoading(async () => { + await toast.promise( + acceptSubtaskSubmission({ + authAtlasSpace, + userPrincipal, + taskId: BigInt(taskId), + subtaskId: BigInt(subtaskId), + }), + { + loading: "Accepting task...", + success: "Task accepted successfully.", + error: "Failed to accept task.", + } + ); + await getSpaceTasks({ + spaceId, + unAuthAtlasSpace, + dispatch, + }); + }, dispatch); + }; + + const singleSubmissionState = Object.keys(submission.submissionData.state)[0]; + + const renderTitleAndDescription = () => { + if ('DiscordTask' in task.task_content) { + return ( + <> +

+ {task.task_content.DiscordTask.task_title} +

+

+ {task.task_content.DiscordTask.task_description} +

+ + ); + } + if ('TitleAndDescription' in task.task_content) { + return ( + <> +

+ {task.task_content.TitleAndDescription.task_title} +

+

+ {task.task_content.TitleAndDescription.task_description} +

+ + ); + } + return null; + }; + + const renderSubmissionContent = () => { + if ('Discord' in submission.submissionData.submission) { + const { username, user_id } = submission.submissionData.submission.Discord; + const creationDate = getDiscordAccountCreationDate(user_id.toString()); + return ( +
+
+

Username: {username}

+

Account Creation Date: {creationDate.toLocaleDateString()}

+
+
+ ); + } + return

{submission.submissionData.submission.Text.content}

; + }; + + return ( +
+ {singleSubmissionState} {taskId} {subtaskId} + {renderTitleAndDescription()} +
+

Submitted response:

+
+ {renderSubmissionContent()} +
+ {submissionState === "Rejected" && + submission.submissionData.rejection_reason[0] && + submission.submissionData.rejection_reason[0].trim().length > 0 && ( +
+

Reject Reason:

+

+ {submission.submissionData.rejection_reason[0]} +

+
+ )} +
+ {submissionState === "WaitingForReview" && + singleSubmissionState === "WaitingForReview" && ( + <> +
+ + +
+ + )} +
+
+ {showRejectPopup && ( +
+
+

+ Reason for Rejection +

+
+ + {descriptionError && ( + {descriptionError} + )} +

Guild ID:

+ { + setSelectedGuild(guild); + onChange({ + target: { name: `tasks.${index}.guildId`, value: guild.id }, + }); + }} + /> + {guildIdError && ( + {guildIdError} + )} + {!accessToken && ( + + )} +

Invitation Link:

+ { + setInviteLink(e.target.value); + onInviteLinkChange(e); + }} + disabled={!selectedGuild} + className={`border-2 p-2 rounded-xl bg-white text-black w-full ${ + !selectedGuild ? "bg-gray-200" : "" + } ${ + validationState.status === "validating" + ? "border-yellow-500" + : validationState.status === "invalid" + ? "border-red-500" + : validationState.status === "valid" + ? "border-green-500" + : "" + } ${inviteLinkError && "border-red-500"}`} + /> +
+ {validationState.status === "validating" && ( + Validating... + )} + {validationState.status === "invalid" && ( + {validationState.error} + )} + {validationState.status === "valid" && ( + + Invite link is valid! + {' Expires at: '} + {validationState.expiresAt + ? new Date(validationState.expiresAt).toLocaleString() + : 'Never'} + + )} + {validationState.status === "idle" && inviteLinkError && ( + {inviteLinkError} + )} +
+
+ ); +}; +export default DiscordTask; diff --git a/src/atlas_frontend/src/store/slices/userSlice.ts b/src/atlas_frontend/src/store/slices/userSlice.ts index 6e240d8..a6e37ac 100644 --- a/src/atlas_frontend/src/store/slices/userSlice.ts +++ b/src/atlas_frontend/src/store/slices/userSlice.ts @@ -78,6 +78,7 @@ interface UserState { }; blockchain: StorableUser | null; userHub: string | null; + accessToken: string | null; } const initialState = (): UserState => { @@ -88,6 +89,7 @@ const initialState = (): UserState => { }, userHub: null, blockchain: null, + accessToken: null, }; }; @@ -107,6 +109,11 @@ export const userSlice = createSlice({ ...action.payload, }; }, + setDiscordUserAccessToken: (state, action: PayloadAction<{ + accessToken: string; + }>) => { + state.accessToken = action.payload.accessToken; + }, }, selectors: { selectUserBlockchainData: (userState: UserState) => { @@ -122,11 +129,16 @@ export const userSlice = createSlice({ selectUserTxs: (userState: UserState) => { return userState.txs; }, + selectDiscordUserAccessToken: (userState: UserState) => { + if(userState.accessToken) + return userState.accessToken; + return null; + } }, }); -export const { setUserBlockchainData, setCkUsdcBalance, appendUserTxs } = +export const { setUserBlockchainData, setCkUsdcBalance, appendUserTxs, setDiscordUserAccessToken } = userSlice.actions; -export const { selectUserBlockchainData, selectUserCkUsdc, selectUserTxs } = +export const { selectUserBlockchainData, selectUserCkUsdc, selectUserTxs, selectDiscordUserAccessToken } = userSlice.selectors; export default userSlice.reducer; diff --git a/src/atlas_frontend/src/utils/discord.ts b/src/atlas_frontend/src/utils/discord.ts new file mode 100644 index 0000000..604bf9e --- /dev/null +++ b/src/atlas_frontend/src/utils/discord.ts @@ -0,0 +1,59 @@ +import type { DiscordInviteApiResponse } from "../components/Integrations/discord/types"; + +export interface DiscordValidationResult { + status: "valid" | "invalid"; + expiresAt?: string | null; + error?: string | null; +} + +export const createTimeoutPromise = (timeoutMs: number = 5000): Promise => { + return new Promise((_, reject) => + setTimeout( + () => reject(new Error("Validation timed out.")), + timeoutMs + ) + ); +}; + +export const validateDiscordInviteLink = async ( + inviteLink: string, + guildId: string, + validateDiscordInviteFn: (inviteCode: string, guildId: string) => Promise +): Promise => { + try { + const inviteCodeMatch = inviteLink.match( + /(?:https?:\/\/)?(?:discord\.(?:gg|com\/invite)\/)?([a-zA-Z0-9-]+)/ + ); + const inviteCode = inviteCodeMatch ? inviteCodeMatch[1] : null; + + if (!inviteCode) { + throw new Error("Invalid invite link."); + } + + const validationPromise = validateDiscordInviteFn(inviteCode, guildId); + const timeoutPromise = createTimeoutPromise(); + + const result = await Promise.race([validationPromise, timeoutPromise]); + + if (result && result.expires_at && result.expires_at.length > 0) { + return { + status: "valid", + expiresAt: result.expires_at, + }; + } else { + return { status: "valid" }; + } + } catch (err) { + console.error("Validation error:", err); + let message = "An unknown error occurred during validation."; + if (err instanceof Error) { + message = err.message || "Invalid invite link or guild ID."; + } else if (typeof err === "string") { + message = err; + } + return { + status: "invalid", + error: message, + }; + } +}; \ No newline at end of file diff --git a/src/atlas_frontend/src/utils/loading.ts b/src/atlas_frontend/src/utils/loading.ts index 9256d79..e29f098 100644 --- a/src/atlas_frontend/src/utils/loading.ts +++ b/src/atlas_frontend/src/utils/loading.ts @@ -1,5 +1,5 @@ -import { setScreenBlur } from "../store/slices/appSlice"; +import { setLoading, setScreenBlur } from "../store/slices/appSlice"; import type { AppDispatch } from "../store/store"; export async function runWithLoading( @@ -7,11 +7,11 @@ export async function runWithLoading( dispatch: AppDispatch, finallyCallback?: () => void ): Promise { - dispatch(setScreenBlur(true)); + dispatch(setLoading(true)); try { await fn(); } finally { finallyCallback && finallyCallback() - dispatch(setScreenBlur(false)); + dispatch(setLoading(false)); } } diff --git a/src/atlas_frontend/src/utils/taskMapper.ts b/src/atlas_frontend/src/utils/taskMapper.ts new file mode 100644 index 0000000..c651910 --- /dev/null +++ b/src/atlas_frontend/src/utils/taskMapper.ts @@ -0,0 +1,55 @@ +export enum TaskType { + Generic = "generic", + Discord = "discord", +} + +type TaskInput = { + taskType: TaskType; + title: string; + description: string; + guildId?: string; + inviteLink?: string; + allowresubmit: boolean; +}; + +interface BaseTaskContent { + title: string, + description: string, + allow_resubmit: boolean, +} + +export interface GenericTaskContent extends BaseTaskContent { + task_type: "generic"; +}; + +export interface DiscordTaskContent extends BaseTaskContent { + task_type: "discord"; + invite_link: string; + guild_id: string; +}; + +type TaskContent = GenericTaskContent | DiscordTaskContent; + +type MapperFn = (task: TaskInput) => TaskContent; + +const taskMappers: Record = { + [TaskType.Generic]: (task) => ({ + task_type: "generic", + title: task.title, + description: task.description, + allow_resubmit: task.allowresubmit, + }), + + [TaskType.Discord]: (task) => ({ + task_type: "discord", + title: task.title, + description: task.description, + invite_link: task.inviteLink!, + guild_id: task.guildId!, + allow_resubmit: task.allowresubmit, + }), +}; + +export const mapTasks = (tasks: TaskInput[]): TaskContent[] => { + return tasks.map((task) => taskMappers[task.taskType](task)); +}; diff --git a/src/atlas_frontend/src/utils/tasks.ts b/src/atlas_frontend/src/utils/tasks.ts new file mode 100644 index 0000000..2a635a2 --- /dev/null +++ b/src/atlas_frontend/src/utils/tasks.ts @@ -0,0 +1,51 @@ +import type { Principal } from "@dfinity/principal"; +import type { SubmissionData, Task, TaskType, TokenReward } from "../../../declarations/atlas_space/atlas_space.did"; + +type Submission = [Principal, SubmissionData]; + +type SubmissionExtractor = ( + task: Extract> +) => Submission[]; + +const submissionExtractors: { + [K in keyof TaskType]?: SubmissionExtractor; +} = { + GenericTask: (t: Extract) => t.GenericTask.submission ?? [], + DiscordTask: (t: Extract) => t.DiscordTask.submission ?? [], +}; + +export class BlockchainTask implements Task { + public tasks: TaskType[]; + public creator: Principal; + public task_title: string; + public token_reward: TokenReward; + public rewarded: Principal[]; + public number_of_uses: bigint; + + constructor(public task: Task) { + this.tasks = task.tasks; + this.creator = task.creator; + this.task_title = task.task_title; + this.token_reward = task.token_reward; + this.rewarded = task.rewarded; + this.number_of_uses = task.number_of_uses; + } + + getSubmissions(): Submission[] { + const last = this.tasks.at(-1); + if (!last) return []; + + for (const [key, extractor] of Object.entries(submissionExtractors)) { + if (key in last) { + return (extractor as (task: TaskType) => Submission[])(last); + } + } + return []; + } + + getAcceptedSubmissions(): number { + return this.getSubmissions().filter( + ([, submission]) => "Accepted" in submission.state + ).length; + } +} diff --git a/src/atlas_main/Cargo.toml b/src/atlas_main/Cargo.toml index 57b6ccc..4f50d4f 100644 --- a/src/atlas_main/Cargo.toml +++ b/src/atlas_main/Cargo.toml @@ -10,6 +10,7 @@ crate-type = ["cdylib"] candid = { workspace = true } hex = { workspace = true } ic-cdk = { workspace = true } +ic-management-canister-types = { workspace = true } ic-stable-structures = { workspace = true } minicbor = { workspace = true } minicbor-derive = { workspace = true } diff --git a/src/atlas_space/Cargo.toml b/src/atlas_space/Cargo.toml index ee2116f..56ca297 100644 --- a/src/atlas_space/Cargo.toml +++ b/src/atlas_space/Cargo.toml @@ -10,6 +10,7 @@ crate-type = ["cdylib"] candid = { workspace = true } hex = { workspace = true } ic-cdk = { workspace = true } +ic-management-canister-types = { workspace = true } ic-stable-structures = { workspace = true } minicbor = { workspace = true } minicbor-derive = { workspace = true } @@ -21,3 +22,4 @@ ethnum = { workspace = true } num-bigint = { workspace = true } icrc-ledger-types = "0.1.8" sha2 = { workspace = true } +serde_json = { workspace = true} diff --git a/src/atlas_space/atlas_space.did b/src/atlas_space/atlas_space.did index 0af99f6..c612d79 100644 --- a/src/atlas_space/atlas_space.did +++ b/src/atlas_space/atlas_space.did @@ -77,7 +77,10 @@ type State = record { tasks_count : nat64; space_description : text; }; -type Submission = variant { Text : record { content : text } }; +type Submission = variant { + Text : record { content : text }; + Discord : record { username : text; user_id : nat64 }; +}; type SubmissionData = record { state : SubmissionState; rejection_reason : opt text; @@ -93,6 +96,13 @@ type Task = record { number_of_uses : nat64; }; type TaskContent = variant { + DiscordTask : record { + task_description : text; + task_title : text; + invite_link : text; + guild_id : text; + allow_resubmit : bool; + }; TitleAndDescription : record { task_description : text; task_title : text; @@ -100,6 +110,10 @@ type TaskContent = variant { }; }; type TaskType = variant { + DiscordTask : record { + task_content : TaskContent; + submission : vec record { principal; SubmissionData }; + }; GenericTask : record { task_content : TaskContent; submission : vec record { principal; SubmissionData }; diff --git a/src/atlas_space/src/task/mod.rs b/src/atlas_space/src/task/mod.rs index 98e00ae..130eb1e 100644 --- a/src/atlas_space/src/task/mod.rs +++ b/src/atlas_space/src/task/mod.rs @@ -48,6 +48,19 @@ pub enum TaskContent { #[n(2)] allow_resubmit: bool, }, + #[n(1)] + DiscordTask { + #[n(0)] + task_title: String, + #[n(1)] + task_description: String, + #[n(2)] + guild_id: String, + #[n(3)] + invite_link: String, + #[n(4)] + allow_resubmit: bool, + }, } impl TaskContent { @@ -70,12 +83,40 @@ impl TaskContent { } Ok(()) } + TaskContent::DiscordTask { + task_title, + task_description, + guild_id, + invite_link, + allow_resubmit: _, + } => { + if task_title.trim().len() > 50 { + return Err(Error::InvalidTaskContent( + "Subtask title is too long (max length: 50)".into(), + )); + } + if task_description.trim().len() > 500 { + return Err(Error::InvalidTaskContent( + "Subtask description is too long (max length: 500)".into(), + )); + } + if guild_id.trim().is_empty() { + return Err(Error::InvalidTaskContent("Guild ID cannot be empty".into())); + } + if invite_link.trim().is_empty() { + return Err(Error::InvalidTaskContent( + "Discord invite link cannot be empty".into(), + )); + } + Ok(()) + } } } pub fn allow_resubmit(&self) -> bool { match self { TaskContent::TitleAndDescription { allow_resubmit, .. } => *allow_resubmit, + TaskContent::DiscordTask { allow_resubmit, .. } => *allow_resubmit, } } } @@ -95,6 +136,22 @@ impl From<&TaskContent> for TaskType { }, submission: Default::default(), }, + TaskContent::DiscordTask { + task_title, + task_description, + guild_id, + invite_link, + allow_resubmit, + } => Self::DiscordTask { + task_content: TaskContent::DiscordTask { + task_title: task_title.clone(), + task_description: task_description.clone(), + guild_id: guild_id.clone(), + invite_link: invite_link.clone(), + allow_resubmit: *allow_resubmit, + }, + submission: Default::default(), + }, } } } @@ -108,45 +165,75 @@ pub enum TaskType { #[cbor(n(1), with = "shared::cbor::principal::b_tree_map")] submission: BTreeMap, }, + #[n(1)] + DiscordTask { + #[n(0)] + task_content: TaskContent, + #[cbor(n(1), with = "shared::cbor::principal::b_tree_map")] + submission: BTreeMap, + }, } impl TaskType { pub fn submit(&mut self, user: Principal, submission: Submission) -> Result<(), Error> { let allow_resubmit = self.get_allow_resubmit(); + match self { - TaskType::GenericTask { - task_content: _, - submission: submissions_map, - } => { - if let Some(existing_submission) = submissions_map.get(&user) { - if existing_submission.get_state() == &SubmissionState::Rejected - && allow_resubmit - { - submissions_map.remove(&user); - } else { - return Err(Error::UserAlreadySubmitted); + TaskType::GenericTask { .. } => { + if let Submission::Text { content } = &submission { + if content.trim().is_empty() { + return Err(Error::InvalidTaskContent( + "Submission cannot be empty".into(), + )); } - } - if !submission.is_text() { + } else { return Err(Error::IncorrectSubmission("Text".to_string())); } - match &submission { - Submission::Text { content } => content.trim().len(), - }; - - submissions_map.insert( - user, - SubmissionData::new(submission, SubmissionState::default()), - ); - Ok(()) + } + TaskType::DiscordTask { .. } => { + if let Submission::Discord { username, user_id } = &submission { + if username.trim().is_empty() || *user_id == 0 { + return Err(Error::InvalidTaskContent( + "Submission cannot be empty".into(), + )); + } + } else { + return Err(Error::IncorrectSubmission("Discord".to_string())); + } + } + }; + + let submissions_map = match self { + TaskType::GenericTask { submission, .. } | TaskType::DiscordTask { submission, .. } => { + submission + } + }; + + if let Some(existing_submission) = submissions_map.get(&user) { + if existing_submission.get_state() == &SubmissionState::Rejected && allow_resubmit { + submissions_map.remove(&user); + } else { + return Err(Error::UserAlreadySubmitted); } } + + submissions_map.insert( + user, + SubmissionData::new(submission, SubmissionState::default()), + ); + + Ok(()) } + pub fn accept(&mut self, user: Principal) -> Result<(), Error> { match self { TaskType::GenericTask { task_content: _, submission: submissions_map, + } + | TaskType::DiscordTask { + task_content: _, + submission: submissions_map, } => { let submission = submissions_map .get_mut(&user) @@ -162,6 +249,10 @@ impl TaskType { TaskType::GenericTask { task_content: _, submission: submissions_map, + } + | TaskType::DiscordTask { + task_content: _, + submission: submissions_map, } => { let submission = submissions_map .get_mut(&user) @@ -179,6 +270,10 @@ impl TaskType { TaskType::GenericTask { task_content: _, submission: submissions_map, + } + | TaskType::DiscordTask { + task_content: _, + submission: submissions_map, } => Ok(submissions_map .get(&user) .ok_or(Error::UserSubmissionNotFound)?), @@ -187,6 +282,7 @@ impl TaskType { pub fn get_allow_resubmit(&self) -> bool { match self { TaskType::GenericTask { task_content, .. } => task_content.allow_resubmit(), + TaskType::DiscordTask { task_content, .. } => task_content.allow_resubmit(), } } } diff --git a/src/atlas_space/src/task/submission.rs b/src/atlas_space/src/task/submission.rs index 3a21fff..da92fb7 100644 --- a/src/atlas_space/src/task/submission.rs +++ b/src/atlas_space/src/task/submission.rs @@ -22,12 +22,13 @@ pub enum Submission { #[n(0)] content: String, }, -} - -impl Submission { - pub fn is_text(&self) -> bool { - matches!(self, Submission::Text { .. }) - } + #[n(1)] + Discord { + #[n(0)] + username: String, + #[n(1)] + user_id: u64, + }, } #[derive(Eq, PartialEq, Debug, Decode, Encode, Clone, CandidType)] diff --git a/src/declarations/atlas_space/atlas_space.did b/src/declarations/atlas_space/atlas_space.did index 0af99f6..c612d79 100644 --- a/src/declarations/atlas_space/atlas_space.did +++ b/src/declarations/atlas_space/atlas_space.did @@ -77,7 +77,10 @@ type State = record { tasks_count : nat64; space_description : text; }; -type Submission = variant { Text : record { content : text } }; +type Submission = variant { + Text : record { content : text }; + Discord : record { username : text; user_id : nat64 }; +}; type SubmissionData = record { state : SubmissionState; rejection_reason : opt text; @@ -93,6 +96,13 @@ type Task = record { number_of_uses : nat64; }; type TaskContent = variant { + DiscordTask : record { + task_description : text; + task_title : text; + invite_link : text; + guild_id : text; + allow_resubmit : bool; + }; TitleAndDescription : record { task_description : text; task_title : text; @@ -100,6 +110,10 @@ type TaskContent = variant { }; }; type TaskType = variant { + DiscordTask : record { + task_content : TaskContent; + submission : vec record { principal; SubmissionData }; + }; GenericTask : record { task_content : TaskContent; submission : vec record { principal; SubmissionData }; diff --git a/src/declarations/atlas_space/atlas_space.did.d.ts b/src/declarations/atlas_space/atlas_space.did.d.ts index 89ca934..07f3bfa 100644 --- a/src/declarations/atlas_space/atlas_space.did.d.ts +++ b/src/declarations/atlas_space/atlas_space.did.d.ts @@ -83,7 +83,8 @@ export interface State { 'tasks_count' : bigint, 'space_description' : string, } -export type Submission = { 'Text' : { 'content' : string } }; +export type Submission = { 'Text' : { 'content' : string } } | + { 'Discord' : { 'username' : string, 'user_id' : bigint } }; export interface SubmissionData { 'state' : SubmissionState, 'rejection_reason' : [] | [string], @@ -101,6 +102,15 @@ export interface Task { 'number_of_uses' : bigint, } export type TaskContent = { + 'DiscordTask' : { + 'task_description' : string, + 'task_title' : string, + 'invite_link' : string, + 'guild_id' : string, + 'allow_resubmit' : boolean, + } + } | + { 'TitleAndDescription' : { 'task_description' : string, 'task_title' : string, @@ -108,6 +118,12 @@ export type TaskContent = { } }; export type TaskType = { + 'DiscordTask' : { + 'task_content' : TaskContent, + 'submission' : Array<[Principal, SubmissionData]>, + } + } | + { 'GenericTask' : { 'task_content' : TaskContent, 'submission' : Array<[Principal, SubmissionData]>, diff --git a/src/declarations/atlas_space/atlas_space.did.js b/src/declarations/atlas_space/atlas_space.did.js index b6befa2..a940f3b 100644 --- a/src/declarations/atlas_space/atlas_space.did.js +++ b/src/declarations/atlas_space/atlas_space.did.js @@ -48,6 +48,13 @@ export const idlFactory = ({ IDL }) => { 'CkUsdc' : IDL.Record({ 'amount' : IDL.Nat }), }); const TaskContent = IDL.Variant({ + 'DiscordTask' : IDL.Record({ + 'task_description' : IDL.Text, + 'task_title' : IDL.Text, + 'invite_link' : IDL.Text, + 'guild_id' : IDL.Text, + 'allow_resubmit' : IDL.Bool, + }), 'TitleAndDescription' : IDL.Record({ 'task_description' : IDL.Text, 'task_title' : IDL.Text, @@ -76,6 +83,7 @@ export const idlFactory = ({ IDL }) => { }); const Submission = IDL.Variant({ 'Text' : IDL.Record({ 'content' : IDL.Text }), + 'Discord' : IDL.Record({ 'username' : IDL.Text, 'user_id' : IDL.Nat64 }), }); const SubmissionData = IDL.Record({ 'state' : SubmissionState, @@ -83,6 +91,10 @@ export const idlFactory = ({ IDL }) => { 'submission' : Submission, }); const TaskType = IDL.Variant({ + 'DiscordTask' : IDL.Record({ + 'task_content' : TaskContent, + 'submission' : IDL.Vec(IDL.Tuple(IDL.Principal, SubmissionData)), + }), 'GenericTask' : IDL.Record({ 'task_content' : TaskContent, 'submission' : IDL.Vec(IDL.Tuple(IDL.Principal, SubmissionData)),