diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000000..e69de29bb2 diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000000..4bb08f9338 --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,57 @@ +{ + "env": { + "browser": true, + "es6": true + }, + "extends": [ + "next/core-web-vitals", + "eslint:recommended", + "plugin:react/recommended", + "plugin:@typescript-eslint/eslint-recommended" + ], + "globals": { + "Atomics": "readonly", + "SharedArrayBuffer": "readonly" + }, + "parser": "@typescript-eslint/parser", + "parserOptions": { + "ecmaFeatures": { + "jsx": true + }, + "ecmaVersion": 2020, + "sourceType": "module" + }, + "plugins": [ + "@typescript-eslint", + "header", + "promise", + "react", + "react-hooks" + ], + "settings": { + "react": { + "pragma": "React", // Pragma to use, default to "React" + "version": "18.2.0" // React version, default to the latest React stable release + } + }, + "rules": { + "react/react-in-jsx-scope": "off", + "@typescript-eslint/no-unused-vars": "error", + "arrow-spacing":["warn",{ "before": true, "after": true }], + "comma-dangle": ["error", "never"], + "header/header": [2, "./header.js"], + "indent": ["error", "tab"], + "no-multiple-empty-lines": ["error", {"max": 1}], + "no-tabs": ["error", { "allowIndentationTabs": true }], + "no-trailing-spaces": ["warn"], + "no-unused-vars": "off", + "object-curly-spacing": ["error", "always"], + "quotes": ["error", "single", { "avoidEscape": true }], + "react-hooks/rules-of-hooks": "error", + "react-hooks/exhaustive-deps": "warn", + "semi": [2, "always"], + // "simple-import-sort/imports": "error", + "sort-keys": ["error", "asc", {"caseSensitive": true, "natural": false, "minKeys": 2}], + "switch-colon-spacing": ["error", {"after": true, "before": false}] + } +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000..80932e58b1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,45 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem +.vscode + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# local env files +.env*.local + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts + +# Sentry +.sentryclirc + +# Sentry +next.config.original.js + +.env \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000000..c87e0421d2 --- /dev/null +++ b/README.md @@ -0,0 +1,34 @@ +This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). + +## Getting Started + +First, run the development server: + +```bash +npm run dev +# or +yarn dev +``` + +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. + +You can start editing the page by modifying `pages/index.tsx`. The page auto-updates as you edit the file. + +[API routes](https://nextjs.org/docs/api-routes/introduction) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.ts`. + +The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/api-routes/introduction) instead of React pages. + +## Learn More + +To learn more about Next.js, take a look at the following resources: + +- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. +- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. + +You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! + +## Deploy on Vercel + +The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. + +Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000000..35c839ee82 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,7 @@ +# Security Policy + +## Reporting a Vulnerability + +Contact: mailto:hello@polkassembly.io +Encryption: https://kusama.polkassembly.io/pgp-key.txt +Preferred-Languages: en diff --git a/dayjs-init.ts b/dayjs-init.ts new file mode 100644 index 0000000000..1f7a84767c --- /dev/null +++ b/dayjs-init.ts @@ -0,0 +1,18 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import dayjs from 'dayjs'; +import duration from 'dayjs/plugin/duration'; +import isBetween from 'dayjs/plugin/isBetween'; +import relativeTime from 'dayjs/plugin/relativeTime'; +import utc from 'dayjs/plugin/utc'; + +dayjs.extend(duration); +dayjs.extend(isBetween); +dayjs.extend(relativeTime); +dayjs.extend(utc); + +export { + dayjs +}; \ No newline at end of file diff --git a/header.js b/header.js new file mode 100644 index 0000000000..5075fbe3be --- /dev/null +++ b/header.js @@ -0,0 +1,3 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. diff --git a/jazzicon.d.ts b/jazzicon.d.ts new file mode 100644 index 0000000000..dd55a027ed --- /dev/null +++ b/jazzicon.d.ts @@ -0,0 +1,7 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +declare module '@metamask/jazzicon' { + export default function (diameter: number, seed: number): HTMLElement; +} \ No newline at end of file diff --git a/next.config.js b/next.config.js new file mode 100644 index 0000000000..b0fe3ed6eb --- /dev/null +++ b/next.config.js @@ -0,0 +1,49 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. +/* eslint-disable indent */ + +/* eslint-disable sort-keys */ +/** @type {import('next').NextConfig} */ +const nextConfig = { + async headers() { + return [ + { + // matching all v1 API routes + source: '/api/:path*', + headers: [ + { key: 'Access-Control-Allow-Credentials', value: 'true' }, + { key: 'Access-Control-Allow-Origin', value: '*' }, + { key: 'Access-Control-Allow-Methods', value: 'GET,OPTIONS,PATCH,DELETE,POST,PUT' }, + { key: 'Access-Control-Allow-Headers', value: '*' } + ] + } + ]; + }, + async redirects() { + return [ + { + source: '/news', // this path will be redirected to 404 + destination: '/404', + permanent: true + } + ]; + }, + images: { + domains: ['parachains.info'] + }, + reactStrictMode: true, + compiler: { + styledComponents: true + }, + webpack(config) { + config.module.rules.push({ + test: /\.svg$/, + use: ['@svgr/webpack'] + }); + + return config; + } +}; + +module.exports = nextConfig; diff --git a/package.json b/package.json new file mode 100644 index 0000000000..2dde580303 --- /dev/null +++ b/package.json @@ -0,0 +1,105 @@ +{ + "name": "polkassembly-nextjs", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint", + "lint:fix": "next lint --fix", + "api:bump": "yarn add @polkadot/api @polkadot/api-augment @polkadot/extension-dapp @polkadot/extension-inject @polkadot/react-identicon @polkadot/ui-settings @polkadot/util @polkadot/util-crypto" + }, + "dependencies": { + "@apollo/client": "^3.7.3", + "@ardatan/graphql-tools": "^4.1.0", + "@metamask/jazzicon": "^2.0.0", + "@next/font": "^13.1.6", + "@polkadot/api": "^9.13.6", + "@polkadot/api-augment": "^9.13.6", + "@polkadot/extension-dapp": "^0.44.8", + "@polkadot/extension-inject": "^0.44.8", + "@polkadot/react-identicon": "^2.11.1", + "@polkadot/ui-settings": "^2.11.1", + "@polkadot/util": "^10.3.1", + "@polkadot/util-crypto": "^10.3.1", + "@sendgrid/mail": "^7.7.0", + "@tinymce/tinymce-react": "^4.2.0", + "@types/jsonwebtoken": "^8.5.9", + "@types/node": "18.11.17", + "@types/react": "18.0.26", + "@types/react-dom": "18.0.9", + "@types/redis": "^4.0.11", + "@types/uuid": "^9.0.0", + "@types/validator": "^13.7.10", + "@typescript-eslint/eslint-plugin": "^5.47.0", + "@typescript-eslint/parser": "^5.47.0", + "@walletconnect/web3-provider": "^1.8.0", + "antd": "^5.0.7", + "argon2": "^0.30.3", + "bn.js": "^5.2.1", + "classnames": "^2.3.2", + "cookie": "^0.5.0", + "dayjs": "^1.11.7", + "detect-browser": "^5.3.0", + "ejs": "^3.1.8", + "eslint": "8.30.0", + "eslint-config-next": "13.0.7", + "eslint-config-standard": "^17.0.0", + "eslint-plugin-header": "^3.1.1", + "eslint-plugin-import": "^2.26.0", + "eslint-plugin-node": "^11.1.0", + "eslint-plugin-promise": "^6.1.1", + "eslint-plugin-react": "^7.31.11", + "eslint-plugin-react-hooks": "^4.6.0", + "eslint-plugin-simple-import-sort": "^8.0.0", + "eslint-plugin-standard": "^5.0.0", + "eth-sig-util": "^3.0.1", + "firebase-admin": "^11.4.1", + "graphql": "^16.6.0", + "graphql-tag": "^2.12.6", + "graphql-toolkit": "^0.7.5", + "ioredis": "^5.3.0", + "jsonwebtoken": "^8.5.1", + "lodash": "^4.17.21", + "next": "13.0.7", + "nextjs-progressbar": "^0.0.16", + "node-fetch": "^3.3.0", + "query-string": "^8.1.0", + "react": "18.2.0", + "react-big-calendar": "^1.6.8", + "react-cmdk": "^1.3.8", + "react-date-picker": "^9.1.0", + "react-dom": "18.2.0", + "react-helmet": "^6.1.0", + "react-json-view": "^1.21.3", + "react-jwt": "^1.1.7", + "react-markdown": "^8.0.4", + "react-mde": "^11.5.0", + "react-minimal-pie-chart": "^8.4.0", + "react-twitter-embed": "^4.0.4", + "rehype-raw": "^6.1.1", + "showdown": "^2.1.0", + "styled-components": "^5.3.6", + "typescript": "4.9.4", + "uuid": "^9.0.0", + "validator": "^13.7.0", + "web3": "~1.6.1" + }, + "devDependencies": { + "@svgr/webpack": "^6.5.1", + "@types/cookie": "^0.5.1", + "@types/ejs": "^3.1.1", + "@types/lodash": "^4.14.191", + "@types/react-big-calendar": "^1.6.1", + "@types/react-helmet": "^6.1.6", + "@types/showdown": "^2.0.0", + "@types/styled-components": "^5.1.26", + "autoprefixer": "^10.4.13", + "postcss": "^8.4.20", + "tailwindcss": "^3.2.4" + }, + "resolutions": { + "styled-components": "^5" + } +} diff --git a/pages/404.tsx b/pages/404.tsx new file mode 100644 index 0000000000..7552edf792 --- /dev/null +++ b/pages/404.tsx @@ -0,0 +1,29 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import { Result } from 'antd'; +import Link from 'next/link'; +import React from 'react'; + +import NothingFoundSVG from '~assets/nothing-found.svg'; + +const NotFound = () => { + return ( + + + + } + title="Uh oh, it seems this route doesn't exist." + extra={ + + Go To Home + + } + /> + ); +}; + +export default NotFound; \ No newline at end of file diff --git a/pages/500.tsx b/pages/500.tsx new file mode 100644 index 0000000000..9aec5a6e7f --- /dev/null +++ b/pages/500.tsx @@ -0,0 +1,22 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import { Result } from 'antd'; +import Link from 'next/link'; +import React from 'react'; + +const NotFound = () => { + return ( + + Go To Home + + } + /> + ); +}; + +export default NotFound; \ No newline at end of file diff --git a/pages/_app.tsx b/pages/_app.tsx new file mode 100644 index 0000000000..6d670b4d35 --- /dev/null +++ b/pages/_app.tsx @@ -0,0 +1,89 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import { Poppins, Roboto_Mono, Work_Sans } from '@next/font/google'; +import { ConfigProvider } from 'antd'; +import type { AppProps } from 'next/app'; +import Image from 'next/image'; +import { useRouter } from 'next/router'; +import NextNProgress from 'nextjs-progressbar'; +import { useEffect, useState } from 'react'; +import AppLayout from 'src/components/AppLayout'; +import CMDK from 'src/components/CMDK'; +import { UserDetailsProvider } from 'src/context/UserDetailsContext'; +import { antdTheme } from 'styles/antdTheme'; + +import { ApiContextProvider } from '~src/context/ApiContext'; +import { AllianceApiContextProvider } from '~src/context/AllianceApiContext'; +import { ModalProvider } from '~src/context/ModalContext'; +import { NetworkContextProvider } from '~src/context/NetworkContext'; +import getNetwork from '~src/util/getNetwork'; + +const poppins = Poppins({ + display: 'swap', + style: ['italic', 'normal'], + subsets: ['latin'], + variable: '--font-poppins', + weight: ['200', '300', '400', '500', '600', '700'] +}); +const robotoMono = Roboto_Mono({ + display: 'swap', + style: 'normal', + subsets: ['latin'], + weight: ['400', '500'] +}); +const workSans = Work_Sans({ + display: 'swap', + subsets: ['latin'] +}); + +import 'antd/dist/reset.css'; +import '../styles/globals.css'; + +export default function App({ Component, pageProps }: AppProps) { + const router = useRouter(); + const [showSplashScreen, setShowSplashScreen] = useState(true); + const [network, setNetwork] = useState(''); + + useEffect(() => { + router.isReady && setShowSplashScreen(false); + }, [router.isReady]); + + useEffect(() => { + if(!global?.window) return; + const networkStr = getNetwork(); + setNetwork(networkStr); + }, []); + + const SplashLoader = () =>
+ {'Loading'} +
; + + return + + + + + + <> + { showSplashScreen && } +
+ + + +
+ +
+
+
+
+
+
; +} diff --git a/pages/_document.tsx b/pages/_document.tsx new file mode 100644 index 0000000000..63a93691db --- /dev/null +++ b/pages/_document.tsx @@ -0,0 +1,17 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import { Head, Html, Main, NextScript } from 'next/document'; + +export default function Document() { + return ( + + + +
+ + + + ); +} diff --git a/pages/alliance-announcements/index.tsx b/pages/alliance-announcements/index.tsx new file mode 100644 index 0000000000..560c90f971 --- /dev/null +++ b/pages/alliance-announcements/index.tsx @@ -0,0 +1,25 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. +import React from 'react'; +import AllianceAnnouncements from '~src/components/Listing/Members/AllianceAnnouncements'; +import SEOHead from '~src/global/SEOHead'; + +const Announcements = () => { + return ( + <> + +

Alliance

+ + {/* Intro and Create Post Button */} +
+

+ The Alliance Pallet provides a collective that curates a list of accounts and URLs, deemed by the voting members to be unscrupulous actors. The Alliance provides a set of ethics against bad behavior, and provides recognition and influence for those teams that contribute something back to the ecosystem. +

+
+ + + ); +}; + +export default Announcements; \ No newline at end of file diff --git a/pages/alliance-members/index.tsx b/pages/alliance-members/index.tsx new file mode 100644 index 0000000000..5d640e17e5 --- /dev/null +++ b/pages/alliance-members/index.tsx @@ -0,0 +1,25 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. +import React from 'react'; +import AllianceMembers from '~src/components/Listing/Members/AllianceMembers'; +import SEOHead from '~src/global/SEOHead'; + +const Members = () => { + return ( + <> + +

Alliance

+ + {/* Intro and Create Post Button */} +
+

+ The Alliance Pallet provides a collective that curates a list of accounts and URLs, deemed by the voting members to be unscrupulous actors. The Alliance provides a set of ethics against bad behavior, and provides recognition and influence for those teams that contribute something back to the ecosystem. +

+
+ + + ); +}; + +export default Members; \ No newline at end of file diff --git a/pages/alliance-unscrupulous/index.tsx b/pages/alliance-unscrupulous/index.tsx new file mode 100644 index 0000000000..7da66da88b --- /dev/null +++ b/pages/alliance-unscrupulous/index.tsx @@ -0,0 +1,25 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. +import React from 'react'; +import AllianceUnscrupulous from 'src/components/Listing/Members/AllianceUnscrupulous'; +import SEOHead from '~src/global/SEOHead'; + +const Unscrupulous = () => { + return ( + <> + +

Alliance

+ + {/* Intro and Create Post Button */} +
+

+ The Alliance Pallet provides a collective that curates a list of accounts and URLs, deemed by the voting members to be unscrupulous actors. The Alliance provides a set of ethics against bad behavior, and provides recognition and influence for those teams that contribute something back to the ecosystem. +

+
+ + + ); +}; + +export default Unscrupulous; \ No newline at end of file diff --git a/pages/api/v1/auth/actions/addCommentReaction.ts b/pages/api/v1/auth/actions/addCommentReaction.ts new file mode 100644 index 0000000000..2940105f48 --- /dev/null +++ b/pages/api/v1/auth/actions/addCommentReaction.ts @@ -0,0 +1,74 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import { NextApiRequest, NextApiResponse } from 'next'; + +import withErrorHandling from '~src/api-middlewares/withErrorHandling'; +import { postsByTypeRef } from '~src/api-utils/firestore_refs'; +import authServiceInstance from '~src/auth/auth'; +import { MessageType } from '~src/auth/types'; +import getTokenFromReq from '~src/auth/utils/getTokenFromReq'; +import messages from '~src/auth/utils/messages'; + +async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== 'POST') return res.status(405).json({ message: 'Invalid request method, POST required.' }); + + const network = String(req.headers['x-network']); + if(!network) return res.status(400).json({ message: 'Missing network name in request headers' }); + + const { userId, postId, commentId, reaction, postType } = req.body; + if(!userId || isNaN(postId) || !commentId || !reaction || !postType) return res.status(400).json({ message: 'Missing parameters in request body' }); + + const token = getTokenFromReq(req); + if(!token) return res.status(400).json({ message: 'Invalid token' }); + + const user = await authServiceInstance.GetUser(token); + if(!user || user.id !== Number(userId)) return res.status(403).json({ message: messages.UNAUTHORISED }); + + const postRef = postsByTypeRef(network, postType).doc(String(postId)); + + const reactionsCollRef = postRef + .collection('comments') + .doc(String(commentId)) + .collection('comment_reactions'); + + const userReactionQuery = reactionsCollRef.where('user_id', '==', user.id); + + let reactionDoc; + let reactionData = {}; + + const userReactionQuerySnapshot = await userReactionQuery.get(); + if(!userReactionQuerySnapshot.empty) { + reactionDoc = userReactionQuerySnapshot.docs[0]; + reactionData = { + ...reactionDoc.data(), + reaction, + updated_at: new Date() + }; + } else { + reactionDoc = postRef + .collection('comments') + .doc(String(commentId)) + .collection('comment_reactions') + .doc(); + + reactionData = { + created_at: new Date(), + id: reactionDoc.id, + reaction, + updated_at: new Date(), + user_id: user.id, + username: user.username + }; + } + + await reactionsCollRef.doc(reactionDoc.id).set(reactionData, { merge: true }).then(() => { + return res.status(200).json({ message: 'Reaction updated.' }); + }).catch((error) => { + console.error('Error updating reaction: ', error); + return res.status(500).json({ message: 'Error updating reaction' }); + }); +} + +export default withErrorHandling(handler); diff --git a/pages/api/v1/auth/actions/addCommentReply.ts b/pages/api/v1/auth/actions/addCommentReply.ts new file mode 100644 index 0000000000..5c654238c7 --- /dev/null +++ b/pages/api/v1/auth/actions/addCommentReply.ts @@ -0,0 +1,72 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import { NextApiHandler } from 'next'; + +import withErrorHandling from '~src/api-middlewares/withErrorHandling'; +import { isOffChainProposalTypeValid, isProposalTypeValid } from '~src/api-utils'; +import { postsByTypeRef } from '~src/api-utils/firestore_refs'; +import authServiceInstance from '~src/auth/auth'; +import { MessageType } from '~src/auth/types'; +import getTokenFromReq from '~src/auth/utils/getTokenFromReq'; +import messages from '~src/auth/utils/messages'; +import { ProposalType } from '~src/global/proposalType'; +import { CommentReply } from '~src/types'; + +export interface IAddCommentReplyResponse { + id: string; +} + +const handler: NextApiHandler = async (req, res) => { + if (req.method !== 'POST') return res.status(405).json({ message: 'Invalid request method, POST required.' }); + + const network = String(req.headers['x-network']); + if(!network) return res.status(400).json({ message: 'Missing network name in request headers' }); + + const { userId, commentId, content, postId, postType } = req.body; + if(!userId || !commentId || !content || isNaN(postId) || !postType) return res.status(400).json({ message: 'Missing parameters in request body' }); + + const strProposalType = String(postType); + if (!isOffChainProposalTypeValid(strProposalType) && !isProposalTypeValid(strProposalType)) return res.status(400).json({ message: `The post type of the name "${postType}" does not exist.` }); + + const token = getTokenFromReq(req); + if(!token) return res.status(400).json({ message: 'Invalid token' }); + + const user = await authServiceInstance.GetUser(token); + if(!user || user.id !== Number(userId)) return res.status(403).json({ message: messages.UNAUTHORISED }); + + const postRef = postsByTypeRef(network, strProposalType as ProposalType) + .doc(String(postId)); + const last_comment_at = new Date(); + const newReplyRef = postRef + .collection('comments') + .doc(String(commentId)) + .collection('replies') + .doc(); + + const newReply: CommentReply = { + content, + created_at: new Date(), + id: newReplyRef.id, + updated_at: last_comment_at, + user_id: user.id, + user_profile_img: user?.profile?.image || '', + username: user.username + }; + + await newReplyRef.set(newReply).then(() => { + postRef.update({ + last_comment_at + }).then(() => {}); + return res.status(200).json({ + id: newReply.id + }); + }).catch((error) => { + // The document probably doesn't exist. + console.error('Error saving comment: ', error); + return res.status(500).json({ message: 'Error saving comment' }); + }); +}; + +export default withErrorHandling(handler); diff --git a/pages/api/v1/auth/actions/addPollVote.ts b/pages/api/v1/auth/actions/addPollVote.ts new file mode 100644 index 0000000000..d31c2d2753 --- /dev/null +++ b/pages/api/v1/auth/actions/addPollVote.ts @@ -0,0 +1,96 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import { NextApiHandler } from 'next'; + +import withErrorHandling from '~src/api-middlewares/withErrorHandling'; +import { isOffChainProposalTypeValid, isValidNetwork } from '~src/api-utils'; +import { postsByTypeRef } from '~src/api-utils/firestore_refs'; +import authServiceInstance from '~src/auth/auth'; +import { MessageType } from '~src/auth/types'; +import getTokenFromReq from '~src/auth/utils/getTokenFromReq'; +import messages from '~src/auth/utils/messages'; +import POLL_TYPE, { isPollTypeValid } from '~src/global/pollTypes'; +import { ProposalType } from '~src/global/proposalType'; +import { IOptionPollVote, IPollVote } from '~src/types'; + +import { getPollCollectionName } from '../../polls'; + +const handler: NextApiHandler = async (req, res) => { + if (req.method !== 'POST') return res.status(405).json({ message: 'Invalid request method, POST required.' }); + + const network = String(req.headers['x-network']); + if(!network || !isValidNetwork(network)) return res.status(400).json({ message: 'Invalid network in request header' }); + + const { pollId, postId, userId, vote, option, pollType, proposalType } = req.body; + if(!pollId || isNaN(postId) || !userId || (pollType === POLL_TYPE.NORMAL && !vote) || (pollType === POLL_TYPE.OPTION && !option)) return res.status(400).json({ message: 'Missing parameters in request body' }); + + const strProposalType = String(proposalType); + if (!isOffChainProposalTypeValid(strProposalType)) return res.status(400).json({ message: `The off chain proposal type of the name "${proposalType}" does not exist.` }); + + const strPollType = String(pollType); + if (!pollType || !isPollTypeValid(strPollType)) return res.status(400).json({ message: `The pollType "${pollType}" is invalid` }); + + const token = getTokenFromReq(req); + if(!token) return res.status(400).json({ message: 'Invalid token' }); + + const user = await authServiceInstance.GetUser(token); + if(!user || user.id !== Number(userId)) return res.status(403).json({ message: messages.UNAUTHORISED }); + + const pollColName = getPollCollectionName(strPollType); + if (!['option_polls', 'polls'].includes(pollColName)) { + return res.status(400).json({ message: `The pollType "${pollType}" is invalid` }); + } + + const pollRef = postsByTypeRef(network, strProposalType as ProposalType) + .doc(String(postId)) + .collection(pollColName) + .doc(String(pollId)); + + const pollDoc = await pollRef.get(); + + if(!pollDoc.exists) return res.status(404).json({ message: 'Poll not found' }); + + const date = new Date(); + let votes_field_name = ''; + let newVote: IPollVote | IOptionPollVote | undefined; + if (strPollType === POLL_TYPE.OPTION) { + newVote = { + created_at: date, + option: option, + updated_at: date, + user_id: Number(userId) + } as IOptionPollVote; + votes_field_name = 'option_poll_votes'; + } else if (strPollType === POLL_TYPE.NORMAL) { + newVote = { + created_at: date, + updated_at: date, + user_id: Number(userId), + vote: vote + } as IPollVote; + votes_field_name = 'poll_votes'; + } else { + return res.status(400).json({ message: `The pollType "${pollType}" is invalid` }); + } + + if (!newVote) { + return res.status(500).json({ message: 'Poll vote object is not created' }); + } + + const updated: any = {}; + const data = pollDoc.data() as any; + + updated[votes_field_name] = data?.[votes_field_name] || []; + updated[votes_field_name].push(newVote); + + pollRef.update(updated).then(() => { + return res.status(200).json({ message: 'Poll vote added.' }); + }).catch((error) => { + console.error('Error adding poll vote: ', error); + return res.status(500).json({ message: 'Error adding poll vote' }); + }); +}; + +export default withErrorHandling(handler); diff --git a/pages/api/v1/auth/actions/addPostComment.ts b/pages/api/v1/auth/actions/addPostComment.ts new file mode 100644 index 0000000000..b4ec72ea2d --- /dev/null +++ b/pages/api/v1/auth/actions/addPostComment.ts @@ -0,0 +1,71 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import { NextApiHandler } from 'next'; + +import withErrorHandling from '~src/api-middlewares/withErrorHandling'; +import { isOffChainProposalTypeValid, isProposalTypeValid } from '~src/api-utils'; +import { postsByTypeRef } from '~src/api-utils/firestore_refs'; +import authServiceInstance from '~src/auth/auth'; +import { MessageType } from '~src/auth/types'; +import getTokenFromReq from '~src/auth/utils/getTokenFromReq'; +import messages from '~src/auth/utils/messages'; +import { ProposalType } from '~src/global/proposalType'; +import { PostComment } from '~src/types'; + +export interface IAddPostCommentResponse { + id: string; +} + +const handler: NextApiHandler = async (req, res) => { + if (req.method !== 'POST') return res.status(405).json({ message: 'Invalid request method, POST required.' }); + + const network = String(req.headers['x-network']); + if(!network) return res.status(400).json({ message: 'Missing network name in request headers' }); + + const { userId, content, postId, postType } = req.body; + if(!userId || !content || isNaN(postId) || !postType) return res.status(400).json({ message: 'Missing parameters in request body' }); + + const strProposalType = String(postType); + if (!isOffChainProposalTypeValid(strProposalType) && !isProposalTypeValid(strProposalType)) return res.status(400).json({ message: `The post type of the name "${postType}" does not exist.` }); + + const token = getTokenFromReq(req); + if(!token) return res.status(400).json({ message: 'Invalid token' }); + + const user = await authServiceInstance.GetUser(token); + if(!user || user.id !== Number(userId)) return res.status(403).json({ message: messages.UNAUTHORISED }); + + const postRef = postsByTypeRef(network, strProposalType as ProposalType) + .doc(String(postId)); + const last_comment_at = new Date(); + const newCommentRef = postRef + .collection('comments') + .doc(); + + const newComment: PostComment = { + content: content, + created_at: new Date(), + id: newCommentRef.id, + sentiment: '', + updated_at: last_comment_at, + user_id: user.id, + user_profile_img: user?.profile?.image || '', + username: user.username + }; + + await newCommentRef.set(newComment).then(() => { + postRef.update({ + last_comment_at + }).then(() => {}); + return res.status(200).json({ + id: newComment.id + }); + }).catch((error) => { + // The document probably doesn't exist. + console.error('Error saving comment: ', error); + return res.status(500).json({ message: 'Error saving comment' }); + }); +}; + +export default withErrorHandling(handler); diff --git a/pages/api/v1/auth/actions/addPostReaction.ts b/pages/api/v1/auth/actions/addPostReaction.ts new file mode 100644 index 0000000000..040cba2ac6 --- /dev/null +++ b/pages/api/v1/auth/actions/addPostReaction.ts @@ -0,0 +1,69 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import { NextApiRequest, NextApiResponse } from 'next'; + +import withErrorHandling from '~src/api-middlewares/withErrorHandling'; +import { postsByTypeRef } from '~src/api-utils/firestore_refs'; +import authServiceInstance from '~src/auth/auth'; +import { MessageType } from '~src/auth/types'; +import getTokenFromReq from '~src/auth/utils/getTokenFromReq'; +import messages from '~src/auth/utils/messages'; + +async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== 'POST') return res.status(405).json({ message: 'Invalid request method, POST required.' }); + + const network = String(req.headers['x-network']); + if(!network) return res.status(400).json({ message: 'Missing network name in request headers' }); + + const { userId, postId, reaction, postType } = req.body; + if(!userId || isNaN(postId) || !reaction || !postType) return res.status(400).json({ message: 'Missing parameters in request body' }); + + const token = getTokenFromReq(req); + if(!token) return res.status(400).json({ message: 'Invalid token' }); + + const user = await authServiceInstance.GetUser(token); + if(!user || user.id !== Number(userId)) return res.status(403).json({ message: messages.UNAUTHORISED }); + + const postRef = postsByTypeRef(network, postType).doc(String(postId)); + const reactionsCollRef = postRef + .collection('post_reactions'); + + const userReactionQuery = reactionsCollRef.where('user_id', '==', user.id); + + let reactionDoc; + let reactionData = {}; + + const userReactionQuerySnapshot = await userReactionQuery.get(); + if(!userReactionQuerySnapshot.empty) { + reactionDoc = userReactionQuerySnapshot.docs[0]; + reactionData = { + ...reactionDoc.data(), + reaction, + updated_at: new Date() + }; + } else { + reactionDoc = postRef + .collection('post_reactions') + .doc(); + + reactionData = { + created_at: new Date(), + id: reactionDoc.id, + reaction, + updated_at: new Date(), + user_id: user.id, + username: user.username + }; + } + + await reactionsCollRef.doc(reactionDoc.id).set(reactionData, { merge: true }).then(() => { + return res.status(200).json({ message: 'Reaction updated.' }); + }).catch((error) => { + console.error('Error updating reaction: ', error); + return res.status(500).json({ message: 'Error updating reaction' }); + }); +} + +export default withErrorHandling(handler); diff --git a/pages/api/v1/auth/actions/addProfile.ts b/pages/api/v1/auth/actions/addProfile.ts new file mode 100644 index 0000000000..b7ac702392 --- /dev/null +++ b/pages/api/v1/auth/actions/addProfile.ts @@ -0,0 +1,64 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import { NextApiRequest, NextApiResponse } from 'next'; + +import withErrorHandling from '~src/api-middlewares/withErrorHandling'; +import authServiceInstance from '~src/auth/auth'; +import { ISocial, MessageType } from '~src/auth/types'; +import getTokenFromReq from '~src/auth/utils/getTokenFromReq'; +import messages from '~src/auth/utils/messages'; +import firebaseAdmin from '~src/services/firebaseInit'; + +async function handler(req: NextApiRequest, res: NextApiResponse) { + const firestore = firebaseAdmin.firestore(); + if (req.method !== 'POST') return res.status(405).json({ message: 'Invalid request method, POST required.' }); + + const { badges: badgesString, bio, image, title, social_links: socialLinksString } = req.body; + if(!badgesString && !bio && !image && !title && !socialLinksString) return res.status(400).json({ message: 'Missing parameters in request body' }); + + const badges = JSON.parse(badgesString); + const social_links = JSON.parse(socialLinksString); + + if(!Array.isArray(badges)) return res.status(400).json({ message: 'Badges must be an array' }); + + if (!Array.isArray(social_links)) return res.status(400).json({ message: 'Social links must be an array' }); + + const newSocialLinks = (social_links as ISocial[]).reduce((prev, curr) => { + if (curr && curr.link && curr.type) { + return [...prev, { + link: curr.link, + type: curr.type + }]; + } + return [...prev]; + }, [] as ISocial[]); + + const token = getTokenFromReq(req); + if(!token) return res.status(400).json({ message: 'Missing user token' }); + + const user = await authServiceInstance.GetUser(token); + if(!user) return res.status(400).json({ message: messages.USER_NOT_FOUND }); + + const userRef = firestore.collection('users').doc(String(user.id)); + + //update profile field in userRef + const profile = { + badges, + bio, + image, + social_links: newSocialLinks, + title + }; + + await userRef.update({ profile }).then(() => { + return res.status(200).json({ message: 'Profile updated.' }); + }).catch((error) => { + // The document probably doesn't exist. + console.error('Error updating document: ', error); + return res.status(500).json({ message: 'Error updating profile' }); + }); +} + +export default withErrorHandling(handler); diff --git a/pages/api/v1/auth/actions/addressLinkConfirm.ts b/pages/api/v1/auth/actions/addressLinkConfirm.ts new file mode 100644 index 0000000000..9e78ecffca --- /dev/null +++ b/pages/api/v1/auth/actions/addressLinkConfirm.ts @@ -0,0 +1,28 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import { NextApiRequest, NextApiResponse } from 'next'; + +import withErrorHandling from '~src/api-middlewares/withErrorHandling'; +import authServiceInstance from '~src/auth/auth'; +import { ChangeResponseType, MessageType } from '~src/auth/types'; +import getTokenFromReq from '~src/auth/utils/getTokenFromReq'; +import messages from '~src/auth/utils/messages'; + +async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== 'POST') return res.status(405).json({ message: 'Invalid request method, POST required.' }); + + const { address, signature } = req.body; + + if(!address || !signature) return res.status(400).json({ message: 'Missing parameters in request body' }); + + const token = getTokenFromReq(req); + if(!token) return res.status(400).json({ message: 'Invalid token' }); + + const updatedJWT = await authServiceInstance.AddressLinkConfirm(token, address, signature); + + return res.status(200).json({ message: messages.ADDRESS_LINKING_SUCCESSFUL, token: updatedJWT }); +} + +export default withErrorHandling(handler); diff --git a/pages/api/v1/auth/actions/addressLinkStart.ts b/pages/api/v1/auth/actions/addressLinkStart.ts new file mode 100644 index 0000000000..589055ea2d --- /dev/null +++ b/pages/api/v1/auth/actions/addressLinkStart.ts @@ -0,0 +1,63 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import { NextApiRequest, NextApiResponse } from 'next'; +import { v4 as uuidv4 } from 'uuid'; + +import withErrorHandling from '~src/api-middlewares/withErrorHandling'; +import authServiceInstance from '~src/auth/auth'; +import { Address, ChallengeMessage, MessageType } from '~src/auth/types'; +import getTokenFromReq from '~src/auth/utils/getTokenFromReq'; +import messages from '~src/auth/utils/messages'; +import firebaseAdmin from '~src/services/firebaseInit'; + +async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== 'POST') return res.status(405).json({ message: 'Invalid request method, POST required.' }); + + const network = String(req.headers['x-network']); + if(!network) return res.status(400).json({ message: 'Missing network in headers' }); + + const { address: addressRes } = req.body; + + let address = addressRes; + if(addressRes.startsWith('0x')) { + address = addressRes.toLowerCase(); + } + + if(!address) return res.status(400).json({ message: 'Missing parameters in request body' }); + + const token = getTokenFromReq(req); + if(!token) return res.status(400).json({ message: 'Invalid token' }); + + const user = await authServiceInstance.GetUser(token); + if(!user) return res.status(400).json({ message: messages.USER_NOT_FOUND }); + + const firestore = firebaseAdmin.firestore(); + + const addressExists = (await firestore.collection('addresses').doc(address).get()).exists; + if(addressExists) return res.status(400).json({ message: messages.ADDRESS_ALREADY_EXISTS }); + + const sign_message = address.startsWith('0x') ? `Link account with polkassembly ${uuidv4()}` : `${uuidv4()}`; + + const newAddress: Address = { + address, + default: false, + is_erc20: address.startsWith('0x'), + network, + public_key: '', + sign_message, + user_id: user.id, + verified: false + }; + + await firestore.collection('addresses').doc(address).set(newAddress).then(() => { + return res.status(200).json({ message: messages.ADDRESS_LINKING_STARTED, signMessage:sign_message }); + }).catch((error) => { + console.log(' Error while address linking start : ', error); + return res.status(400).json({ message: messages.ADDRESS_LINKING_FAILED }); + }); + +} + +export default withErrorHandling(handler); diff --git a/pages/api/v1/auth/actions/addressLogin.ts b/pages/api/v1/auth/actions/addressLogin.ts new file mode 100644 index 0000000000..812eb362cb --- /dev/null +++ b/pages/api/v1/auth/actions/addressLogin.ts @@ -0,0 +1,23 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import { NextApiRequest, NextApiResponse } from 'next'; + +import withErrorHandling from '~src/api-middlewares/withErrorHandling'; +import authServiceInstance from '~src/auth/auth'; +import { MessageType, TokenType } from '~src/auth/types'; + +async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== 'POST') return res.status(405).json({ message: 'Invalid request method, POST required.' }); + + const { address, signature } = req.body; + + if(!address || !signature) return res.status(400).json({ message: 'Missing parameters in request body' }); + + const { token } = await authServiceInstance.AddressLogin(address, signature); + + return res.status(200).json({ token }); +} + +export default withErrorHandling(handler); diff --git a/pages/api/v1/auth/actions/addressLoginStart.ts b/pages/api/v1/auth/actions/addressLoginStart.ts new file mode 100644 index 0000000000..48aaf98469 --- /dev/null +++ b/pages/api/v1/auth/actions/addressLoginStart.ts @@ -0,0 +1,24 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import { NextApiRequest, NextApiResponse } from 'next'; + +import withErrorHandling from '~src/api-middlewares/withErrorHandling'; +import authServiceInstance from '~src/auth/auth'; +import { ChallengeMessage, MessageType } from '~src/auth/types'; +import messages from '~src/auth/utils/messages'; + +async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== 'POST') return res.status(405).json({ message: 'Invalid request method, POST required.' }); + + const { address } = req.body; + + if(!address) return res.status(400).json({ message: 'Missing parameters in request body' }); + + const signMessage = await authServiceInstance.AddressLoginStart(address); + + return res.status(200).json({ message: messages.ADDRESS_LOGIN_STARTED, signMessage }); +} + +export default withErrorHandling(handler); diff --git a/pages/api/v1/auth/actions/addressSignupConfirm.ts b/pages/api/v1/auth/actions/addressSignupConfirm.ts new file mode 100644 index 0000000000..e6b352021f --- /dev/null +++ b/pages/api/v1/auth/actions/addressSignupConfirm.ts @@ -0,0 +1,26 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import { NextApiRequest, NextApiResponse } from 'next'; + +import withErrorHandling from '~src/api-middlewares/withErrorHandling'; +import authServiceInstance from '~src/auth/auth'; +import { MessageType, TokenType } from '~src/auth/types'; + +async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== 'POST') return res.status(405).json({ message: 'Invalid request method, POST required.' }); + + const network = String(req.headers['x-network']); + if(!network) return res.status(400).json({ message: 'Missing network name in request headers' }); + + const { address, signature } = req.body; + + if(!address || !signature) return res.status(400).json({ message: 'Missing parameters in request body' }); + + const { token } = await authServiceInstance.AddressSignupConfirm(network, address, signature); + + return res.status(200).json({ token }); +} + +export default withErrorHandling(handler); diff --git a/pages/api/v1/auth/actions/addressSignupStart.ts b/pages/api/v1/auth/actions/addressSignupStart.ts new file mode 100644 index 0000000000..955a3d6955 --- /dev/null +++ b/pages/api/v1/auth/actions/addressSignupStart.ts @@ -0,0 +1,23 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import { NextApiRequest, NextApiResponse } from 'next'; + +import withErrorHandling from '~src/api-middlewares/withErrorHandling'; +import authServiceInstance from '~src/auth/auth'; +import { ChallengeMessage, MessageType } from '~src/auth/types'; +import messages from '~src/auth/utils/messages'; + +async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== 'POST') return res.status(405).json({ message: 'Invalid request method, POST required.' }); + + const { address } = req.body; + if(!address) return res.status(400).json({ message: 'Missing parameters in request body' }); + + const signMessage = await authServiceInstance.AddressSignupStart(address); + + return res.status(200).json({ message: messages.ADDRESS_SIGNUP_STARTED, signMessage }); +} + +export default withErrorHandling(handler); diff --git a/pages/api/v1/auth/actions/addressUnlink.ts b/pages/api/v1/auth/actions/addressUnlink.ts new file mode 100644 index 0000000000..ca92bf3c1c --- /dev/null +++ b/pages/api/v1/auth/actions/addressUnlink.ts @@ -0,0 +1,27 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import { NextApiRequest, NextApiResponse } from 'next'; + +import withErrorHandling from '~src/api-middlewares/withErrorHandling'; +import authServiceInstance from '~src/auth/auth'; +import { ChangeResponseType, MessageType } from '~src/auth/types'; +import getTokenFromReq from '~src/auth/utils/getTokenFromReq'; +import messages from '~src/auth/utils/messages'; + +async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== 'POST') return res.status(405).json({ message: 'Invalid request method, POST required.' }); + + const { address } = req.body; + if(!address) return res.status(400).json({ message: 'Missing parameters in request body' }); + + const token = getTokenFromReq(req); + if(!token) return res.status(400).json({ message: 'Invalid token' }); + + const updatedJWT = await authServiceInstance.AddressUnlink(token, address); + + return res.status(200).json({ message: messages.ADDRESS_UNLINKING_SUCCESS, token: updatedJWT }); +} + +export default withErrorHandling(handler); diff --git a/pages/api/v1/auth/actions/changeAbout.ts b/pages/api/v1/auth/actions/changeAbout.ts new file mode 100644 index 0000000000..8d8c054a11 --- /dev/null +++ b/pages/api/v1/auth/actions/changeAbout.ts @@ -0,0 +1,66 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import { NextApiRequest, NextApiResponse } from 'next'; + +import withErrorHandling from '~src/api-middlewares/withErrorHandling'; +import authServiceInstance from '~src/auth/auth'; +import { MessageType, ProfileDetails } from '~src/auth/types'; +import getUserFromUserId from '~src/auth/utils/getUserFromUserId'; +import messages from '~src/auth/utils/messages'; +import verifySignature from '~src/auth/utils/verifySignature'; +import firebaseAdmin from '~src/services/firebaseInit'; + +async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== 'POST') return res.status(405).json({ message: 'Invalid request method, POST required.' }); + + const network = String(req.headers['x-network']); + if(!network) return res.status(400).json({ message: 'Missing network name in request headers' }); + + const { address, title, description, image = '', signature } = req.body; + if(!address || !title || !description || !signature) return res.status(400).json({ message: 'Missing parameters in request body' }); + + const signMessage = `about::network:${network}|address:${address}|title:${title}|description:${description}|image:${image}`; + + const isValidSr = verifySignature(signMessage, address, signature); + if (!isValidSr) return res.status(400).json({ message: messages.ABOUT_INVALID_SIGNATURE }); + + let userId: number; + let newProfile: ProfileDetails = { + badges: [], + bio: description, + image: '', + title + }; + + const firestore = firebaseAdmin.firestore(); + const addressDoc = await firestore.collection('addresses').doc(address).get(); + if(!addressDoc.exists){ + const signMessage = await authServiceInstance.AddressSignupStart(address); + const { user_id } = await authServiceInstance.AddressSignupConfirm(network, address, signMessage); + userId = user_id!; + }else { + userId = addressDoc.data()?.user_id; + const user = await getUserFromUserId(userId); + const oldProfile = user.profile; + newProfile = { + ...oldProfile, + bio: description, + title: title + }; + } + + const userRef = firestore.collection('users').doc(String(userId)); + + //update profile field in userRef + userRef.update({ profile: newProfile }).then(() => { + return res.status(200).json({ message: 'Profile updated.' }); + }).catch((error) => { + // The document probably doesn't exist. + console.error('Error updating document: ', error); + return res.status(500).json({ message: 'Error updating profile' }); + }); +} + +export default withErrorHandling(handler); diff --git a/pages/api/v1/auth/actions/changeEmail.ts b/pages/api/v1/auth/actions/changeEmail.ts new file mode 100644 index 0000000000..e8a9daeba1 --- /dev/null +++ b/pages/api/v1/auth/actions/changeEmail.ts @@ -0,0 +1,36 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import { NextApiRequest, NextApiResponse } from 'next'; + +import withErrorHandling from '~src/api-middlewares/withErrorHandling'; +import { isValidNetwork } from '~src/api-utils'; +import authServiceInstance from '~src/auth/auth'; +import { ChangeResponseType, MessageType } from '~src/auth/types'; +import getTokenFromReq from '~src/auth/utils/getTokenFromReq'; +import isValidEmail from '~src/auth/utils/isValidEmail'; +import messages from '~src/auth/utils/messages'; + +async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== 'POST') return res.status(405).json({ message: 'Invalid request method, POST required.' }); + + const network = String(req.headers['x-network']); + if(!network || !isValidNetwork(network)) res.status(400).json({ message: 'Invalid network in request header' }); + + const body = JSON.parse(req.body); + const { email, password } = body; + + if(!body || !email || !password) return res.status(400).json({ message: 'Missing parameters in request body' }); + + const token = getTokenFromReq(req); + if(!token) return res.status(400).json({ message: 'Invalid token' }); + + if (email == '' || !isValidEmail(email)) return res.status(400).json({ message: messages.INVALID_EMAIL }); + + const updatedJWT = await authServiceInstance.ChangeEmail(token, email, password, network); + + return res.status(200).json({ message: email ? messages.EMAIL_CHANGE_REQUEST_SUCCESSFUL : messages.EMAIL_REMOVE_SUCCESSFUL, token: updatedJWT }); +} + +export default withErrorHandling(handler); diff --git a/pages/api/v1/auth/actions/changeNotificationPreference.ts b/pages/api/v1/auth/actions/changeNotificationPreference.ts new file mode 100644 index 0000000000..0e96eabc58 --- /dev/null +++ b/pages/api/v1/auth/actions/changeNotificationPreference.ts @@ -0,0 +1,42 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import { NextApiRequest, NextApiResponse } from 'next'; + +import withErrorHandling from '~src/api-middlewares/withErrorHandling'; +import { isValidNetwork } from '~src/api-utils'; +import authServiceInstance from '~src/auth/auth'; +import { MessageType, NotificationSettings, UpdatedDataResponseType } from '~src/auth/types'; +import getTokenFromReq from '~src/auth/utils/getTokenFromReq'; +import messages from '~src/auth/utils/messages'; + +async function handler(req: NextApiRequest, res: NextApiResponse | MessageType>) { + if (req.method !== 'POST') return res.status(405).json({ message: 'Invalid request method, POST required.' }); + + const network = String(req.headers['x-network']); + if(!network || !isValidNetwork(network)) res.status(400).json({ message: 'Invalid network in request header' }); + + const { new_proposal, own_proposal, post_created, post_participated } = req.body; + + if( new_proposal === undefined || own_proposal === undefined || post_created === undefined || post_participated === undefined ) { + return res.status(400).json({ message: 'Missing parameters in body' }); + } + + const token = getTokenFromReq(req); + if(!token) return res.status(400).json({ message: 'Invalid token' }); + + try { + const notification_settings = await authServiceInstance.ChangeNotificationPreference(token, { + new_proposal, + own_proposal, + post_created, + post_participated + }, network); + return res.status(200).json({ message: messages.NOTIFICATION_PREFERENCE_CHANGE_SUCCESSFUL, updated: notification_settings }); + } catch (error) { + return res.status(Number(error.name)).json({ message: error?.message }); + } +} + +export default withErrorHandling(handler); diff --git a/pages/api/v1/auth/actions/changePassword.ts b/pages/api/v1/auth/actions/changePassword.ts new file mode 100644 index 0000000000..636f4616cd --- /dev/null +++ b/pages/api/v1/auth/actions/changePassword.ts @@ -0,0 +1,30 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import { NextApiRequest, NextApiResponse } from 'next'; + +import withErrorHandling from '~src/api-middlewares/withErrorHandling'; +import authServiceInstance from '~src/auth/auth'; +import { MessageType } from '~src/auth/types'; +import getTokenFromReq from '~src/auth/utils/getTokenFromReq'; +import isValidPassowrd from '~src/auth/utils/isValidPassowrd'; +import messages from '~src/auth/utils/messages'; + +async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== 'POST') return res.status(405).json({ message: 'Invalid request method, POST required.' }); + + const { oldPassword, newPassword } = req.body; + if(!oldPassword || !newPassword) return res.status(400).json({ message: 'Missing parameters in request body' }); + + const token = getTokenFromReq(req); + if(!token) return res.status(400).json({ message: 'Invalid token' }); + + if(!isValidPassowrd(newPassword)) return res.status(400).json({ message: messages.PASSWORD_LENGTH_ERROR }); + + await authServiceInstance.ChangePassword(token, oldPassword, newPassword); + + return res.status(200).json({ message: messages.PASSWORD_CHANGE_SUCCESSFUL }); +} + +export default withErrorHandling(handler); diff --git a/pages/api/v1/auth/actions/changeUsername.ts b/pages/api/v1/auth/actions/changeUsername.ts new file mode 100644 index 0000000000..019ba1ae35 --- /dev/null +++ b/pages/api/v1/auth/actions/changeUsername.ts @@ -0,0 +1,31 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import { NextApiRequest, NextApiResponse } from 'next'; + +import withErrorHandling from '~src/api-middlewares/withErrorHandling'; +import authServiceInstance from '~src/auth/auth'; +import { ChangeResponseType, MessageType } from '~src/auth/types'; +import getTokenFromReq from '~src/auth/utils/getTokenFromReq'; +import isValidUsername from '~src/auth/utils/isValidUsername'; +import messages from '~src/auth/utils/messages'; + +async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== 'POST') return res.status(405).json({ message: 'Invalid request method, POST required.' }); + + const body = JSON.parse(req.body); + const { username, password } = body; + if(!body || !username || !password) return res.status(400).json({ message: 'Missing parameters in request body' }); + + const token = getTokenFromReq(req); + if(!token) return res.status(400).json({ message: 'Invalid token' }); + + if(!isValidUsername(username)) return res.status(400).json({ message: messages.USERNAME_INVALID_ERROR }); + + const updatedJWT = await authServiceInstance.ChangeUsername(token, username, password); + + return res.status(200).json({ message: messages.NOTIFICATION_PREFERENCE_CHANGE_SUCCESSFUL, token: updatedJWT }); +} + +export default withErrorHandling(handler); diff --git a/pages/api/v1/auth/actions/createEvent.ts b/pages/api/v1/auth/actions/createEvent.ts new file mode 100644 index 0000000000..f30026ebb4 --- /dev/null +++ b/pages/api/v1/auth/actions/createEvent.ts @@ -0,0 +1,66 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import { dayjs } from 'dayjs-init'; +import { NextApiRequest, NextApiResponse } from 'next'; + +import withErrorHandling from '~src/api-middlewares/withErrorHandling'; +import authServiceInstance from '~src/auth/auth'; +import { MessageType } from '~src/auth/types'; +import getTokenFromReq from '~src/auth/utils/getTokenFromReq'; +import messages from '~src/auth/utils/messages'; +import firebaseAdmin from '~src/services/firebaseInit'; +import { NetworkEvent } from '~src/types'; + +async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== 'POST') return res.status(405).json({ message: 'Invalid request method, POST required.' }); + + const { content, + end_time, + event_type, + location, + module, + network, + start_time, + title, + url, + user_id } = req.body; + + if(!end_time || !event_type || !network || !start_time || !title || !user_id ) { + return res.status(400).json({ message: 'Missing parameters in request body' }); + } + + const token = getTokenFromReq(req); + if(!token) return res.status(400).json({ message: 'Invalid token' }); + + const user = await authServiceInstance.GetUser(token); + if(!user || user.id !== Number(user_id)) return res.status(403).json({ message: messages.UNAUTHORISED }); + const firestore = firebaseAdmin.firestore(); + + const eventDocRef = firestore.collection('networks').doc(network).collection('events').doc(); + + const newEvent: NetworkEvent = { + content, + end_time: dayjs(end_time).toDate(), + event_type: event_type, + id: eventDocRef.id, + location, + module, + post_id: -1, + start_time: dayjs(start_time).toDate(), + status: 'pending', + title, + url, + user_id: Number(user_id) + }; + + await eventDocRef.set(newEvent).then(() => { + return res.status(200).json({ message: 'Event added.' }); + }).catch((error) => { + console.error('Error adding event : ', error); + return res.status(500).json({ message: 'Error adding event' }); + }); +} + +export default withErrorHandling(handler); diff --git a/pages/api/v1/auth/actions/createPoll.ts b/pages/api/v1/auth/actions/createPoll.ts new file mode 100644 index 0000000000..e57bcb78af --- /dev/null +++ b/pages/api/v1/auth/actions/createPoll.ts @@ -0,0 +1,96 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import { NextApiHandler } from 'next'; + +import withErrorHandling from '~src/api-middlewares/withErrorHandling'; +import { isOffChainProposalTypeValid, isValidNetwork } from '~src/api-utils'; +import { postsByTypeRef } from '~src/api-utils/firestore_refs'; +import authServiceInstance from '~src/auth/auth'; +import { MessageType } from '~src/auth/types'; +import getTokenFromReq from '~src/auth/utils/getTokenFromReq'; +import messages from '~src/auth/utils/messages'; +import POLL_TYPE, { isPollTypeValid } from '~src/global/pollTypes'; +import { ProposalType } from '~src/global/proposalType'; +import { IOptionPoll, IPoll } from '~src/types'; + +import { getPollCollectionName } from '../../polls'; + +export interface ICreatePollResponse { + id: string; +} + +const handler: NextApiHandler = async (req, res) => { + if (req.method !== 'POST') return res.status(405).json({ message: 'Invalid request method, POST required.' }); + + const network = String(req.headers['x-network']); + if(!network || !isValidNetwork(network)) return res.status(400).json({ message: 'Invalid network in request header' }); + + const { endAt, options: optionsString, question, blockEnd, postId, proposalType, pollType } = req.body; + + const strPollType = String(pollType); + if (!pollType || !isPollTypeValid(strPollType)) return res.status(400).json({ message: `The pollType "${pollType}" is invalid` }); + + if(isNaN(postId) || !proposalType || (strPollType === 'normal' && (!blockEnd && blockEnd !== 0)) || (strPollType === 'option' && ((!endAt && endAt !== 0) || !optionsString || !question))) return res.status(400).json({ message: 'Missing parameters in request body' }); + + const strProposalType = String(proposalType); + if (!isOffChainProposalTypeValid(strProposalType)) return res.status(400).json({ message: `The off chain proposal type "${proposalType}" is invalid.` }); + + const token = getTokenFromReq(req); + if(!token) return res.status(400).json({ message: 'Invalid token' }); + + const user = await authServiceInstance.GetUser(token); + if(!user) return res.status(403).json({ message: messages.UNAUTHORISED }); + + const postDocRef = postsByTypeRef(network, strProposalType as ProposalType).doc(String(postId)); + const postDoc = await postDocRef.get(); + if(!postDoc.exists) return res.status(400).json({ message: 'Post does not exist' }); + + if(postDoc.data()?.user_id !== user.id) return res.status(403).json({ message: messages.UNAUTHORISED }); + + const date = new Date(); + let poll: IOptionPoll | IPoll | undefined; + if (strPollType === POLL_TYPE.OPTION) { + const options = JSON.parse(optionsString); + if (!options|| !Array.isArray(options)) return res.status(400).json({ message: 'Options should be an array' }); + poll = { + created_at: date, + end_at: Number(endAt), + id: '', + option_poll_votes: [], + options, + question, + updated_at: date + } as IOptionPoll; + } else if (strPollType === POLL_TYPE.NORMAL) { + poll = { + block_end: Number(blockEnd), + created_at: date, + id: '', + poll_votes: [], + updated_at: date + } as IPoll; + } else { + return res.status(400).json({ message: `The pollType "${pollType}" is invalid` }); + } + + const pollRef = postDocRef.collection(getPollCollectionName(strPollType)).doc(); + if (!poll) { + return res.status(500).json({ message: 'Poll object is not created' }); + } else { + poll.id = pollRef.id; + } + + await pollRef.set(poll).then(() => { + return res.status(200).json({ + id: pollRef.id + }); + }).catch((error) => { + // The document probably doesn't exist. + console.error('Error saving poll: ', error); + return res.status(500).json({ message: 'Error saving poll' }); + }); +}; + +export default withErrorHandling(handler); diff --git a/pages/api/v1/auth/actions/createPost.ts b/pages/api/v1/auth/actions/createPost.ts new file mode 100644 index 0000000000..c3357d3758 --- /dev/null +++ b/pages/api/v1/auth/actions/createPost.ts @@ -0,0 +1,68 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import { NextApiRequest, NextApiResponse } from 'next'; + +import withErrorHandling from '~src/api-middlewares/withErrorHandling'; +import { isOffChainProposalTypeValid, isValidNetwork } from '~src/api-utils'; +import { postsByTypeRef } from '~src/api-utils/firestore_refs'; +import authServiceInstance from '~src/auth/auth'; +import { CreatePostResponseType } from '~src/auth/types'; +import getDefaultUserAddressFromId from '~src/auth/utils/getDefaultUserAddressFromId'; +import getTokenFromReq from '~src/auth/utils/getTokenFromReq'; +import messages from '~src/auth/utils/messages'; +import { ProposalType } from '~src/global/proposalType'; +import { Post } from '~src/types'; + +async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== 'POST') return res.status(405).json({ message: 'Invalid request method, POST required.' }); + + const network = String(req.headers['x-network']); + if(!network || !isValidNetwork(network)) return res.status(400).json({ message: 'Invalid network in request header' }); + + const { content, proposalType, title, topicId, userId } = req.body; + if(!content || !title || !topicId || !userId || !proposalType) return res.status(400).json({ message: 'Missing parameters in request body' }); + + const strProposalType = String(proposalType); + if (!isOffChainProposalTypeValid(strProposalType)) return res.status(400).json({ message: `The off chain proposal type "${proposalType}" is invalid.` }); + + const token = getTokenFromReq(req); + if(!token) return res.status(400).json({ message: 'Invalid token' }); + + const user = await authServiceInstance.GetUser(token); + if(!user || user.id != Number(userId)) return res.status(403).json({ message: messages.UNAUTHORISED }); + + const lastPostQuerySnapshot = await postsByTypeRef(network, strProposalType as ProposalType).orderBy('id', 'desc').limit(1).get(); + const postsCount = lastPostQuerySnapshot.empty ? 0 : lastPostQuerySnapshot.docs[0].data().id || 0; + const newID = Number(postsCount) + 1; + + const userDefaultAddress = await getDefaultUserAddressFromId(Number(userId)); + + const postDocRef = postsByTypeRef(network, strProposalType as ProposalType).doc(String(newID)); + + const last_comment_at = new Date(); + const newPost: Post = { + content, + created_at: new Date(), + id: newID, + last_comment_at, + last_edited_at: last_comment_at, + post_link: null, + proposer_address: userDefaultAddress?.address || '', + title, + topic_id: strProposalType === ProposalType.GRANTS? 6:Number(topicId), + user_id: user.id, + username: user.username + }; + + await postDocRef.set(newPost).then(() => { + return res.status(200).json({ message: 'Post saved.', post_id: newID }); + }).catch((error) => { + // The document probably doesn't exist. + console.error('Error saving post: ', error); + return res.status(500).json({ message: 'Error saving post' }); + }); +} + +export default withErrorHandling(handler); diff --git a/pages/api/v1/auth/actions/createPostConfirm.ts b/pages/api/v1/auth/actions/createPostConfirm.ts new file mode 100644 index 0000000000..ce0bc85398 --- /dev/null +++ b/pages/api/v1/auth/actions/createPostConfirm.ts @@ -0,0 +1,42 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import { NextApiRequest, NextApiResponse } from 'next'; + +import withErrorHandling from '~src/api-middlewares/withErrorHandling'; +import { isOffChainProposalTypeValid, isValidNetwork } from '~src/api-utils'; +import authServiceInstance from '~src/auth/auth'; +import { MessageType } from '~src/auth/types'; +import getTokenFromReq from '~src/auth/utils/getTokenFromReq'; +import { ProposalType } from '~src/global/proposalType'; + +async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== 'POST') return res.status(405).json({ message: 'Invalid request method, POST required.' }); + + const network = String(req.headers['x-network']); + if(!network || !isValidNetwork(network)) return res.status(400).json({ message: 'Invalid network in request header' }); + + const body = JSON.parse(req.body); + const { address, title, content, signature, proposalType } = body; + if(!body || !address || !title || !content || !signature || !proposalType) return res.status(400).json({ message: 'Missing parameters in request body' }); + + const strProposalType = String(proposalType); + if (!isOffChainProposalTypeValid(strProposalType)) return res.status(400).json({ message: `The off chain proposal type "${proposalType}" is invalid.` }); + + const token = getTokenFromReq(req); + if(!token) return res.status(400).json({ message: 'Invalid token' }); + + await authServiceInstance.CreatePostConfirm( + network, + address, + title, + content, + signature, + strProposalType as ProposalType + ); + + return res.status(200).json({ message: 'Post created successfully' }); +} + +export default withErrorHandling(handler); diff --git a/pages/api/v1/auth/actions/createPostStart.ts b/pages/api/v1/auth/actions/createPostStart.ts new file mode 100644 index 0000000000..c551adbc37 --- /dev/null +++ b/pages/api/v1/auth/actions/createPostStart.ts @@ -0,0 +1,24 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import { NextApiRequest, NextApiResponse } from 'next'; + +import withErrorHandling from '~src/api-middlewares/withErrorHandling'; +import authServiceInstance from '~src/auth/auth'; +import { ChallengeMessage, MessageType } from '~src/auth/types'; +import messages from '~src/auth/utils/messages'; + +async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== 'POST') return res.status(405).json({ message: 'Invalid request method, POST required.' }); + + const { address } = req.body; + + if(!address) return res.status(400).json({ message: 'Missing parameters in request body' }); + + const signMessage = await authServiceInstance.CreatePostStart(address); + + return res.status(200).json({ message: messages.CREATE_POST_STARTED, signMessage }); +} + +export default withErrorHandling(handler); diff --git a/pages/api/v1/auth/actions/createProposalTracker.ts b/pages/api/v1/auth/actions/createProposalTracker.ts new file mode 100644 index 0000000000..9faf24dee7 --- /dev/null +++ b/pages/api/v1/auth/actions/createProposalTracker.ts @@ -0,0 +1,35 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import { NextApiRequest, NextApiResponse } from 'next'; + +import authServiceInstance from '~src/auth/auth'; +import { ChallengeMessage, MessageType } from '~src/auth/types'; +import getTokenFromReq from '~src/auth/utils/getTokenFromReq'; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== 'POST') return res.status(405).json({ message: 'Invalid request method, POST required.' }); + + const network = String(req.headers['x-network']); + if(!network) return res.status(400).json({ message: 'Missing network name in request headers' }); + + const body = JSON.parse(req.body); + + const { onchain_proposal_id, status, deadline, start_time } = body; + + if(!body || !onchain_proposal_id || !status || !deadline || start_time) return res.status(400).json({ message: 'Missing parameters in request body' }); + + const token = getTokenFromReq(req); + + await authServiceInstance.ProposalTrackerCreate( + onchain_proposal_id, + status, + deadline, + token, + network, + start_time + ); + + return { message: 'Status set successfully' }; +} diff --git a/pages/api/v1/auth/actions/deleteAccount.ts b/pages/api/v1/auth/actions/deleteAccount.ts new file mode 100644 index 0000000000..4cde3d8a3c --- /dev/null +++ b/pages/api/v1/auth/actions/deleteAccount.ts @@ -0,0 +1,27 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import { NextApiRequest, NextApiResponse } from 'next'; + +import withErrorHandling from '~src/api-middlewares/withErrorHandling'; +import authServiceInstance from '~src/auth/auth'; +import { MessageType } from '~src/auth/types'; +import getTokenFromReq from '~src/auth/utils/getTokenFromReq'; +import messages from '~src/auth/utils/messages'; + +async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== 'POST') return res.status(405).json({ message: 'Invalid request method, POST required.' }); + + const { password } = req.body; + if(!password) return res.status(400).json({ message: 'Missing parameters in request body' }); + + const token = getTokenFromReq(req); + if(!token) return res.status(400).json({ message: 'Invalid token' }); + + await authServiceInstance.DeleteAccount(token, password); + + return res.status(200).json({ message: messages.ACCOUNT_DELETE_SUCCESSFUL }); +} + +export default withErrorHandling(handler); diff --git a/pages/api/v1/auth/actions/deleteComment.ts b/pages/api/v1/auth/actions/deleteComment.ts new file mode 100644 index 0000000000..4ba3052c20 --- /dev/null +++ b/pages/api/v1/auth/actions/deleteComment.ts @@ -0,0 +1,54 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import { NextApiRequest, NextApiResponse } from 'next'; + +import withErrorHandling from '~src/api-middlewares/withErrorHandling'; +import { postsByTypeRef } from '~src/api-utils/firestore_refs'; +import authServiceInstance from '~src/auth/auth'; +import { MessageType } from '~src/auth/types'; +import getTokenFromReq from '~src/auth/utils/getTokenFromReq'; +import messages from '~src/auth/utils/messages'; + +async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== 'POST') return res.status(405).json({ message: 'Invalid request method, POST required.' }); + + const network = String(req.headers['x-network']); + if(!network) return res.status(400).json({ message: 'Missing network name in request headers' }); + + const { commentId, postId, postType } = req.body; + if(!commentId || isNaN(postId) || !postType) return res.status(400).json({ message: 'Missing parameters in request body' }); + + const token = getTokenFromReq(req); + if(!token) return res.status(400).json({ message: 'Invalid token' }); + + const user = await authServiceInstance.GetUser(token); + if(!user) return res.status(403).json({ message: messages.UNAUTHORISED }); + + const postRef = postsByTypeRef(network, postType) + .doc(String(postId)); + const last_comment_at = new Date(); + + const commentRef = postRef + .collection('comments') + .doc(String(commentId)); + + const commentDoc = await commentRef.get(); + + if(!commentDoc.exists) return res.status(404).json({ message: 'Comment not found' }); + if(commentDoc.data()?.user_id !== user.id) return res.status(403).json({ message: messages.UNAUTHORISED }); + + commentRef.delete().then(() => { + postRef.update({ + last_comment_at + }).then(() => {}); + return res.status(200).json({ message: 'Comment saved.' }); + }).catch((error) => { + // The document probably doesn't exist. + console.error('Error deleting comment: ', error); + return res.status(500).json({ message: 'Error deleting comment' }); + }); +} + +export default withErrorHandling(handler); diff --git a/pages/api/v1/auth/actions/deleteCommentReply.ts b/pages/api/v1/auth/actions/deleteCommentReply.ts new file mode 100644 index 0000000000..0e7780b983 --- /dev/null +++ b/pages/api/v1/auth/actions/deleteCommentReply.ts @@ -0,0 +1,56 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import { NextApiRequest, NextApiResponse } from 'next'; + +import withErrorHandling from '~src/api-middlewares/withErrorHandling'; +import { postsByTypeRef } from '~src/api-utils/firestore_refs'; +import authServiceInstance from '~src/auth/auth'; +import { MessageType } from '~src/auth/types'; +import getTokenFromReq from '~src/auth/utils/getTokenFromReq'; +import messages from '~src/auth/utils/messages'; + +async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== 'POST') return res.status(405).json({ message: 'Invalid request method, POST required.' }); + + const network = String(req.headers['x-network']); + if(!network) return res.status(400).json({ message: 'Missing network name in request headers' }); + + const { commentId, postId, postType, replyId } = req.body; + if(!commentId || isNaN(postId) || !postType || !replyId) return res.status(400).json({ message: 'Missing parameters in request body' }); + + const token = getTokenFromReq(req); + if(!token) return res.status(400).json({ message: 'Invalid token' }); + + const user = await authServiceInstance.GetUser(token); + if(!user) return res.status(403).json({ message: messages.UNAUTHORISED }); + + const postRef = postsByTypeRef(network, postType) + .doc(String(postId)); + const last_comment_at = new Date(); + + const replyRef = postRef + .collection('comments') + .doc(String(commentId)) + .collection('replies') + .doc(String(replyId)); + + const replyDoc = await replyRef.get(); + + if(!replyDoc.exists) return res.status(404).json({ message: 'Reply not found' }); + if(replyDoc.data()?.user_id !== user.id) return res.status(403).json({ message: messages.UNAUTHORISED }); + + replyRef.delete().then(() => { + postRef.update({ + last_comment_at + }).then(() => {}); + return res.status(200).json({ message: 'Reply deleted.' }); + }).catch((error) => { + // The document probably doesn't exist. + console.error('Error deleting reply: ', error); + return res.status(500).json({ message: 'Error deleting reply' }); + }); +} + +export default withErrorHandling(handler); diff --git a/pages/api/v1/auth/actions/deletePollVote.ts b/pages/api/v1/auth/actions/deletePollVote.ts new file mode 100644 index 0000000000..9ee5ad0468 --- /dev/null +++ b/pages/api/v1/auth/actions/deletePollVote.ts @@ -0,0 +1,83 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import { NextApiHandler } from 'next'; + +import withErrorHandling from '~src/api-middlewares/withErrorHandling'; +import { isOffChainProposalTypeValid, isValidNetwork } from '~src/api-utils'; +import { postsByTypeRef } from '~src/api-utils/firestore_refs'; +import authServiceInstance from '~src/auth/auth'; +import { MessageType } from '~src/auth/types'; +import getTokenFromReq from '~src/auth/utils/getTokenFromReq'; +import messages from '~src/auth/utils/messages'; +import POLL_TYPE, { isPollTypeValid } from '~src/global/pollTypes'; +import { ProposalType } from '~src/global/proposalType'; + +import { getPollCollectionName } from '../../polls'; + +const handler: NextApiHandler = async (req, res) => { + if (req.method !== 'POST') return res.status(405).json({ message: 'Invalid request method, POST required.' }); + + const network = String(req.headers['x-network']); + if(!network || !isValidNetwork(network)) return res.status(400).json({ message: 'Invalid network in request header' }); + + const { pollId, postId, userId, pollType, proposalType } = req.body; + if(!pollId || isNaN(postId) || !userId) return res.status(400).json({ message: 'Missing parameters in request body' }); + + const strProposalType = String(proposalType); + if (!isOffChainProposalTypeValid(strProposalType)) return res.status(400).json({ message: `The off chain proposal type of the name "${proposalType}" does not exist.` }); + + const strPollType = String(pollType); + if (!pollType || !isPollTypeValid(strPollType)) return res.status(400).json({ message: `The pollType "${pollType}" is invalid` }); + + const token = getTokenFromReq(req); + if(!token) return res.status(400).json({ message: 'Invalid token' }); + + const user = await authServiceInstance.GetUser(token); + if(!user || user.id !== Number(userId)) return res.status(403).json({ message: messages.UNAUTHORISED }); + + const pollColName = getPollCollectionName(strPollType); + if (!['option_polls', 'polls'].includes(pollColName)) { + return res.status(400).json({ message: `The pollType "${pollType}" is invalid` }); + } + + const pollRef = postsByTypeRef(network, strProposalType as ProposalType) + .doc(String(postId)) + .collection(pollColName) + .doc(String(pollId)); + + const pollDoc = await pollRef.get(); + + if(!pollDoc.exists) return res.status(404).json({ message: 'Poll not found' }); + + let votes_field_name = ''; + if (strPollType === POLL_TYPE.OPTION) { + votes_field_name = 'option_poll_votes'; + } else if (strPollType === POLL_TYPE.NORMAL) { + votes_field_name = 'poll_votes'; + } else { + return res.status(400).json({ message: `The pollType "${pollType}" is invalid` }); + } + + const updated: any = {}; + const data = pollDoc.data() as any; + + updated[votes_field_name] = data?.[votes_field_name] || []; + if (!updated[votes_field_name] || !Array.isArray(updated[votes_field_name])) { + return res.status(500).json({ message: `The pollType "${pollType}" is invalid` }); + } + const initialVotes = updated[votes_field_name].length; + updated[votes_field_name] = updated[votes_field_name].filter((vote: any) => vote.user_id !== user.id); + if(initialVotes === updated[votes_field_name].length) return res.status(400).json({ message: 'No vote found for the user' }); + + pollRef.update(updated).then(() => { + return res.status(200).json({ message: 'Poll vote deleted.' }); + }).catch((error) => { + // The document probably doesn't exist. + console.error('Error deleting poll vote: ', error); + return res.status(500).json({ message: 'Error deleting poll vote' }); + }); +}; + +export default withErrorHandling(handler); diff --git a/pages/api/v1/auth/actions/editCommentReply.ts b/pages/api/v1/auth/actions/editCommentReply.ts new file mode 100644 index 0000000000..569308c3fd --- /dev/null +++ b/pages/api/v1/auth/actions/editCommentReply.ts @@ -0,0 +1,63 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import { NextApiHandler } from 'next'; + +import withErrorHandling from '~src/api-middlewares/withErrorHandling'; +import { isOffChainProposalTypeValid, isProposalTypeValid } from '~src/api-utils'; +import { postsByTypeRef } from '~src/api-utils/firestore_refs'; +import authServiceInstance from '~src/auth/auth'; +import { MessageType } from '~src/auth/types'; +import getTokenFromReq from '~src/auth/utils/getTokenFromReq'; +import messages from '~src/auth/utils/messages'; +import { ProposalType } from '~src/global/proposalType'; + +const handler: NextApiHandler = async (req, res) => { + if (req.method !== 'POST') return res.status(405).json({ message: 'Invalid request method, POST required.' }); + + const network = String(req.headers['x-network']); + if(!network) return res.status(400).json({ message: 'Missing network name in request headers' }); + + const { userId, commentId, content, postId, postType, replyId } = req.body; + if(!userId || !commentId || !content || isNaN(postId) || !postType || !replyId) return res.status(400).json({ message: 'Missing parameters in request body' }); + + const strProposalType = String(postType); + if (!isOffChainProposalTypeValid(strProposalType) && !isProposalTypeValid(strProposalType)) return res.status(400).json({ message: `The post type of the name "${postType}" does not exist.` }); + + const token = getTokenFromReq(req); + if(!token) return res.status(400).json({ message: 'Invalid token' }); + + const user = await authServiceInstance.GetUser(token); + if(!user || user.id !== Number(userId)) return res.status(403).json({ message: messages.UNAUTHORISED }); + + const postRef = postsByTypeRef(network, strProposalType as ProposalType) + .doc(String(postId)); + const last_comment_at = new Date(); + + const replyRef = postRef + .collection('comments') + .doc(String(commentId)) + .collection('replies') + .doc(String(replyId)); + + const replyDoc = await replyRef.get(); + if(!replyDoc.exists) return res.status(404).json({ message: 'Reply not found' }); + if(user.id !== replyDoc.data()?.user_id) return res.status(403).json({ message: messages.UNAUTHORISED }); + + replyRef.update({ + content, + updated_at: last_comment_at + }).then(() => { + postRef.update({ + last_comment_at + }).then(() => {}); + return res.status(200).json({ message: 'Reply saved.' }); + }).catch((error) => { + // The document probably doesn't exist. + console.error('Error saving reply: ', error); + return res.status(500).json({ message: 'Error saving reply' }); + }); +}; + +export default withErrorHandling(handler); diff --git a/pages/api/v1/auth/actions/editPoll.ts b/pages/api/v1/auth/actions/editPoll.ts new file mode 100644 index 0000000000..f076d1c5eb --- /dev/null +++ b/pages/api/v1/auth/actions/editPoll.ts @@ -0,0 +1,70 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import { NextApiHandler } from 'next'; + +import withErrorHandling from '~src/api-middlewares/withErrorHandling'; +import { isOffChainProposalTypeValid, isValidNetwork } from '~src/api-utils'; +import { postsByTypeRef } from '~src/api-utils/firestore_refs'; +import authServiceInstance from '~src/auth/auth'; +import { MessageType } from '~src/auth/types'; +import getTokenFromReq from '~src/auth/utils/getTokenFromReq'; +import messages from '~src/auth/utils/messages'; +import { isPollTypeValid } from '~src/global/pollTypes'; +import { ProposalType } from '~src/global/proposalType'; + +import { getPollCollectionName } from '../../polls'; + +const handler: NextApiHandler = async (req, res) => { + if (req.method !== 'POST') return res.status(405).json({ message: 'Invalid request method, POST required.' }); + + const network = String(req.headers['x-network']); + if(!network || !isValidNetwork(network)) return res.status(400).json({ message: 'Invalid network in request header' }); + + const { pollId, postId, userId, blockEnd, pollType, proposalType } = req.body; + if(!pollId || isNaN(postId) || !userId || !blockEnd) return res.status(400).json({ message: 'Missing parameters in request body' }); + + const strProposalType = String(proposalType); + if (!isOffChainProposalTypeValid(strProposalType)) return res.status(400).json({ message: `The off chain proposal type of the name "${proposalType}" does not exist.` }); + + const strPollType = String(pollType); + if (!pollType || !isPollTypeValid(strPollType)) return res.status(400).json({ message: `The pollType "${pollType}" is invalid` }); + + const token = getTokenFromReq(req); + if(!token) return res.status(400).json({ message: 'Invalid token' }); + + const user = await authServiceInstance.GetUser(token); + if(!user || user.id !== Number(userId)) return res.status(403).json({ message: messages.UNAUTHORISED }); + + const updated: any = {}; + let block_end = 0; + if (strPollType === 'normal') { + block_end = Number(blockEnd); + if (isNaN(block_end)) { + return res.status(400).json({ message: `blockEnd ${blockEnd} must be a number` }); + } else { + updated['block_end'] = block_end; + } + } + // else if we want to edit option poll + + const pollColName = getPollCollectionName(strPollType); + const pollRef = postsByTypeRef(network, strProposalType as ProposalType) + .doc(String(postId)) + .collection(pollColName) + .doc(String(pollId)); + + const pollDoc = await pollRef.get(); + + if(!pollDoc.exists) return res.status(404).json({ message: 'Poll not found' }); + + pollRef.update(updated).then(() => { + return res.status(200).json({ message: 'Poll edited.' }); + }).catch((error) => { + console.error('Error editing poll : ', error); + return res.status(500).json({ message: 'Error in editing poll' }); + }); +}; + +export default withErrorHandling(handler); diff --git a/pages/api/v1/auth/actions/editPost.ts b/pages/api/v1/auth/actions/editPost.ts new file mode 100644 index 0000000000..3be5ddac6e --- /dev/null +++ b/pages/api/v1/auth/actions/editPost.ts @@ -0,0 +1,196 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import { dayjs } from 'dayjs-init'; +import { NextApiHandler } from 'next'; + +import withErrorHandling from '~src/api-middlewares/withErrorHandling'; +import { isOffChainProposalTypeValid, isProposalTypeValid, isValidNetwork } from '~src/api-utils'; +import { postsByTypeRef } from '~src/api-utils/firestore_refs'; +import authServiceInstance from '~src/auth/auth'; +import { MessageType } from '~src/auth/types'; +import getAddressesFromUserId from '~src/auth/utils/getAddressesFromUserId'; +import getDefaultUserAddressFromId from '~src/auth/utils/getDefaultUserAddressFromId'; +import getTokenFromReq from '~src/auth/utils/getTokenFromReq'; +import messages from '~src/auth/utils/messages'; +import { getFirestoreProposalType, getSubsquidProposalType, ProposalType } from '~src/global/proposalType'; +import { GET_PROPOSAL_BY_INDEX_AND_TYPE_V2 } from '~src/queries'; +import { firestore_db } from '~src/services/firebaseInit'; +import { Post } from '~src/types'; +import fetchSubsquid from '~src/util/fetchSubsquid'; +import getSubstrateAddress from '~src/util/getSubstrateAddress'; +import { getTopicFromType, getTopicNameFromTopicId } from '~src/util/getTopicFromType'; + +export interface IEditPostResponse { + content: string; + proposer: string; + title: string; + topic: { + id: number, + name: string + }; + last_edited_at: Date; +} + +const handler: NextApiHandler = async (req, res) => { + if (req.method !== 'POST') return res.status(405).json({ message: 'Invalid request method, POST required.' }); + + const network = String(req.headers['x-network']); + if(!network || !isValidNetwork(network)) return res.status(400).json({ message: 'Invalid network in request header' }); + + const { content, postId, proposalType, title, timeline } = req.body; + if(isNaN(postId) || !title || !content || !proposalType) return res.status(400).json({ message: 'Missing parameters in request body' }); + + const strProposalType = String(proposalType); + if (!isOffChainProposalTypeValid(strProposalType) && !isProposalTypeValid(strProposalType)) return res.status(400).json({ message: `The proposal type of the name "${proposalType}" does not exist.` }); + + const token = getTokenFromReq(req); + if(!token) return res.status(400).json({ message: 'Invalid token' }); + + const user = await authServiceInstance.GetUser(token); + if(!user) return res.status(403).json({ message: messages.UNAUTHORISED }); + + const postDocRef = postsByTypeRef(network, proposalType).doc(String(postId)); + + let created_at = new Date(); + let topic_id = null; + let post_link: any = null; + let proposer_address = ''; + + const userAddresses = await getAddressesFromUserId(user.id, true); + + const postDoc = await postDocRef.get(); + let isAuthor = false; + if(postDoc.exists) { + const post = postDoc.data(); + if(![ProposalType.DISCUSSIONS, ProposalType.GRANTS].includes(proposalType)){ + const substrateAddress = getSubstrateAddress(post?.proposer_address || ''); + isAuthor = Boolean(userAddresses.find(address => address.address === substrateAddress)); + if(network === 'moonbeam' && proposalType === ProposalType.DEMOCRACY_PROPOSALS && post?.id === 23){ + if(userAddresses.find(address => address.address === '0xbb1e1722513a8fa80f7593617bb0113b1258b7f1')){ + isAuthor = true; + } + } + if(network === 'moonriver' && proposalType === ProposalType.REFERENDUM_V2 && post?.id === 3){ + if(userAddresses.find(address => address.address === '0x16095c509f728721ad19a51704fc39116157be3a')){ + isAuthor = true; + } + } + } + else if(post?.user_id === user.id){ + isAuthor = true; + } + + if(!isAuthor) return res.status(403).json({ message: messages.UNAUTHORISED }); + created_at = post?.created_at?.toDate(); + topic_id = post?.topic_id; + post_link = post?.post_link; + proposer_address = post?.proposer_address; + }else { + const defaultUserAddress = await getDefaultUserAddressFromId(user.id); + proposer_address = defaultUserAddress?.address || ''; + + const subsquidProposalType = getSubsquidProposalType(proposalType as any); + const postQuery = GET_PROPOSAL_BY_INDEX_AND_TYPE_V2; + let variables: any = { + index_eq: Number(postId), + type_eq: subsquidProposalType + }; + + if (proposalType === ProposalType.TIPS) { + variables = { + hash_eq: String(postId), + type_eq: subsquidProposalType + }; + } + + const postRes = await fetchSubsquid({ + network, + query: postQuery, + variables + }); + + const post = postRes.data?.proposals?.[0]; + + if(!post) return res.status(500).json({ message: 'Something went wrong.' }); + if(!post?.proposer && !post?.preimage?.proposer) return res.status(500).json({ message: 'Something went wrong.' }); + + const proposerAddress = post?.proposer || post?.preimage?.proposer; + + const substrateAddress = getSubstrateAddress(proposerAddress); + if(!substrateAddress) return res.status(500).json({ message: 'Something went wrong.' }); + proposer_address = substrateAddress; + + let isAuthor = userAddresses.find(address => address.address === substrateAddress); + if(network === 'moonbeam' && proposalType === ProposalType.DEMOCRACY_PROPOSALS && post.index === 23){ + isAuthor = userAddresses.find(address => address.address === '0xbb1e1722513a8fa80f7593617bb0113b1258b7f1'); + } + if(network === 'moonriver' && proposalType === ProposalType.REFERENDUM_V2 && post.index === 3){ + isAuthor = userAddresses.find(address => address.address === '0x16095c509f728721ad19a51704fc39116157be3a'); + } + + created_at = dayjs(post.createdAt).toDate(); + + if(!isAuthor) return res.status(403).json({ message: messages.UNAUTHORISED }); + } + + const last_comment_at = new Date(); + const newPostDoc: Post = { + content, + created_at, + id: proposalType === ProposalType.TIPS ? postId : Number(postId), + last_comment_at, + last_edited_at: last_comment_at, + post_link: post_link || null, + proposer_address: proposer_address, + title, + topic_id : topic_id || getTopicFromType(proposalType).id, + user_id: user.id, + username: user.username + }; + + let isCurrPostUpdated = false; + if (timeline && Array.isArray(timeline) && timeline.length > 0) { + const batch = firestore_db.batch(); + timeline.forEach((obj) => { + const proposalType = getFirestoreProposalType(obj.type) as ProposalType; + const postDocRef = postsByTypeRef(network, proposalType).doc(String(obj.index)); + if (strProposalType === proposalType && Number(obj.index) === Number(postId)) { + isCurrPostUpdated = true; + batch.set(postDocRef, newPostDoc, { merge: true }); + } + batch.set(postDocRef, { + content, + created_at, + id: proposalType === ProposalType.TIPS ? obj.hash : Number(obj.index), + last_edited_at: new Date(), + post_link: post_link || null, + proposer_address: proposer_address, + title, + topic_id : getTopicFromType(proposalType).id, + user_id: user.id, + username: user.username + }, { merge: true }); + }); + await batch.commit(); + } + + if (!isCurrPostUpdated) { + await postDocRef.set(newPostDoc, { merge: true }); + } + + const { last_edited_at, topic_id: topicId } = newPostDoc; + return res.status(200).json({ + content, + last_edited_at: last_edited_at, + proposer: proposer_address, + title, + topic: { + id: topicId, + name: getTopicNameFromTopicId(topicId as any) + } + }); +}; + +export default withErrorHandling(handler); diff --git a/pages/api/v1/auth/actions/editPostComment.ts b/pages/api/v1/auth/actions/editPostComment.ts new file mode 100644 index 0000000000..61c6b3c64b --- /dev/null +++ b/pages/api/v1/auth/actions/editPostComment.ts @@ -0,0 +1,61 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import { NextApiHandler } from 'next'; + +import withErrorHandling from '~src/api-middlewares/withErrorHandling'; +import { isOffChainProposalTypeValid, isProposalTypeValid } from '~src/api-utils'; +import { postsByTypeRef } from '~src/api-utils/firestore_refs'; +import authServiceInstance from '~src/auth/auth'; +import { MessageType } from '~src/auth/types'; +import getTokenFromReq from '~src/auth/utils/getTokenFromReq'; +import messages from '~src/auth/utils/messages'; +import { ProposalType } from '~src/global/proposalType'; + +const handler: NextApiHandler< MessageType> = async (req, res) => { + if (req.method !== 'POST') return res.status(405).json({ message: 'Invalid request method, POST required.' }); + + const network = String(req.headers['x-network']); + if(!network) return res.status(400).json({ message: 'Missing network name in request headers' }); + + const { userId, commentId, content, postId, postType } = req.body; + if(!userId || !commentId || !content || isNaN(postId) || !postType) return res.status(400).json({ message: 'Missing parameters in request body' }); + + const strProposalType = String(postType); + if (!isOffChainProposalTypeValid(strProposalType) && !isProposalTypeValid(strProposalType)) return res.status(400).json({ message: `The post type of the name "${postType}" does not exist.` }); + + const token = getTokenFromReq(req); + if(!token) return res.status(400).json({ message: 'Invalid token' }); + + const user = await authServiceInstance.GetUser(token); + if(!user || user.id !== Number(userId)) return res.status(403).json({ message: messages.UNAUTHORISED }); + + const postRef = postsByTypeRef(network, strProposalType as ProposalType) + .doc(String(postId)); + const last_comment_at = new Date(); + + const commentRef = postRef + .collection('comments') + .doc(String(commentId)); + + const commentDoc = await commentRef.get(); + if(!commentDoc.exists) return res.status(404).json({ message: 'Comment not found' }); + if(user.id !== commentDoc.data()?.user_id) return res.status(403).json({ message: messages.UNAUTHORISED }); + + commentRef.update({ + content, + updated_at: last_comment_at + }).then(() => { + postRef.update({ + last_comment_at + }).then(() => {}); + return res.status(200).json({ message: 'Comment saved.' }); + }).catch((error) => { + // The document probably doesn't exist. + console.error('Error saving comment: ', error); + return res.status(500).json({ message: 'Error saving comment' }); + }); +}; + +export default withErrorHandling(handler); diff --git a/pages/api/v1/auth/actions/editPostConfirm.ts b/pages/api/v1/auth/actions/editPostConfirm.ts new file mode 100644 index 0000000000..fb435f1554 --- /dev/null +++ b/pages/api/v1/auth/actions/editPostConfirm.ts @@ -0,0 +1,37 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import { NextApiRequest, NextApiResponse } from 'next'; + +import withErrorHandling from '~src/api-middlewares/withErrorHandling'; +import authServiceInstance from '~src/auth/auth'; +import { MessageType } from '~src/auth/types'; +import getTokenFromReq from '~src/auth/utils/getTokenFromReq'; + +async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== 'POST') return res.status(405).json({ message: 'Invalid request method, POST required.' }); + const network = String(req.headers['x-network']); + if(!network) return res.status(400).json({ message: 'Missing network in request header' }); + + const body = JSON.parse(req.body); + const { address, title, content, signature, proposalType, proposalId } = body; + if(!body || !address || !title || !content || !signature || !proposalType || !proposalId) return res.status(400).json({ message: 'Missing parameters in request body' }); + + const token = getTokenFromReq(req); + if(!token) return res.status(400).json({ message: 'Invalid token' }); + + await authServiceInstance.EditPostConfirm( + network, + address, + title, + content, + signature, + proposalType, + proposalId + ); + + return res.status(200).json({ message: 'Post edited successfully' }); +} + +export default withErrorHandling(handler); diff --git a/pages/api/v1/auth/actions/editPostStart.ts b/pages/api/v1/auth/actions/editPostStart.ts new file mode 100644 index 0000000000..abe3bbe553 --- /dev/null +++ b/pages/api/v1/auth/actions/editPostStart.ts @@ -0,0 +1,26 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import { NextApiRequest, NextApiResponse } from 'next'; + +import withErrorHandling from '~src/api-middlewares/withErrorHandling'; +import authServiceInstance from '~src/auth/auth'; +import { ChallengeMessage, MessageType } from '~src/auth/types'; +import messages from '~src/auth/utils/messages'; + +async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== 'POST') return res.status(405).json({ message: 'Invalid request method, POST required.' }); + + const body = JSON.parse(req.body); + + const { address } = body; + + if(!body || !address) return res.status(400).json({ message: 'Missing parameters in request body' }); + + const signMessage = await authServiceInstance.EditPostStart(address); + + return res.status(200).json({ message: messages.EDIT_POST_STARTED, signMessage }); +} + +export default withErrorHandling(handler); diff --git a/pages/api/v1/auth/actions/linkPostConfirm.ts b/pages/api/v1/auth/actions/linkPostConfirm.ts new file mode 100644 index 0000000000..842aac9a62 --- /dev/null +++ b/pages/api/v1/auth/actions/linkPostConfirm.ts @@ -0,0 +1,322 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import { getTimeline } from './../../posts/on-chain-post'; +import { NextApiHandler } from 'next'; + +import withErrorHandling from '~src/api-middlewares/withErrorHandling'; +import { isOffChainProposalTypeValid, isProposalTypeValid, isValidNetwork } from '~src/api-utils'; +import { postsByTypeRef } from '~src/api-utils/firestore_refs'; +import authServiceInstance from '~src/auth/auth'; +import { MessageType, User } from '~src/auth/types'; +import getAddressesFromUserId from '~src/auth/utils/getAddressesFromUserId'; +import getTokenFromReq from '~src/auth/utils/getTokenFromReq'; +import messages from '~src/auth/utils/messages'; +import { getFirestoreProposalType, getSubsquidProposalType, ProposalType } from '~src/global/proposalType'; +import { GET_PROPOSAL_BY_INDEX_AND_TYPE_FOR_LINKING } from '~src/queries'; +import { firestore_db } from '~src/services/firebaseInit'; +import apiErrorWithStatusCode from '~src/util/apiErrorWithStatusCode'; +import fetchSubsquid from '~src/util/fetchSubsquid'; +import getSubstrateAddress from '~src/util/getSubstrateAddress'; +import { isDataExist } from '../../posts/on-chain-post'; + +interface IUpdatePostLinkInGroupParams { + currPostData: any; + currPostDocRef: FirebaseFirestore.DocumentReference; + currPostId: string | number; + currPostType: string; + postType: string; + postId: string | number; + network: string; + user: User; + isRemove?: boolean; + isTimeline: boolean; +} +type TUpdatePostLinkInGroup = (params: IUpdatePostLinkInGroupParams) => Promise<{ + timeline: any[]; +}>; +export const updatePostLinkInGroup: TUpdatePostLinkInGroup = async (params) => { + const { currPostDocRef, currPostId, currPostType, network, postId, postType, user, currPostData, isRemove, isTimeline } = params; + const subsquidProposalType = getSubsquidProposalType(postType as any); + + const variables: any = { + type_eq: subsquidProposalType + }; + + if (postType === ProposalType.TIPS) { + variables['hash_eq'] = String(postId); + } else { + variables['index_eq'] = Number(postId); + } + const subsquidRes = await fetchSubsquid({ + network, + query: GET_PROPOSAL_BY_INDEX_AND_TYPE_FOR_LINKING, + variables: variables + }); + + // Subsquid Data + const subsquidData = subsquidRes?.data; + if (!isDataExist(subsquidData)) { + throw apiErrorWithStatusCode(`The Post with id: "${postId}" and type: "${postType}" is not found.`, 400); + } + const post = subsquidData.proposals[0]; + const preimage = post?.preimage; + if(!post || (!post?.proposer && !preimage?.proposer)) { + throw apiErrorWithStatusCode('Proposer address is not present in subsquid response.', 400); + } + + const proposerAddress = post.proposer || post.preimage?.proposer; + + const substrateAddress = getSubstrateAddress(proposerAddress); + if(!substrateAddress) { + throw apiErrorWithStatusCode('Something went wrong while getting encoded address corresponding to network', 500); + } + + const userAddresses = await getAddressesFromUserId(user.id, true); + const isAuthor = userAddresses.some(address => address.address === substrateAddress) || (currPostData && user.id === currPostData.user_id); + if (!isAuthor) { + throw apiErrorWithStatusCode(`You can not ${isRemove? 'unlink': 'link'} the post, because you are not the user who created this post`, 403); + } + const batch = firestore_db.batch(); + batch.set(currPostDocRef, { + last_edited_at: new Date(), + post_link: isRemove? null: { + id: postType === 'tips'? postId: Number(postId), + type: postType + } + }, { merge: true }); + + const post_link: any = { + id: currPostType === 'tips'? currPostId: Number(currPostId), + type: currPostType + }; + const postsRefWithData: TPostsRefWithData = []; + const proposals = post?.group?.proposals || undefined; + const timeline = []; + if (proposals || Array.isArray(proposals)) { + (proposals as any[]).forEach((proposal) => { + if (proposal && proposal.type) { + const proposalType = getFirestoreProposalType(proposal.type) as ProposalType; + const id = (proposal.type === 'Tip'? proposal.hash: Number(proposal.index)); + postsRefWithData.push({ + data: { + id + }, + ref: postsByTypeRef(network, proposalType).doc(String(id)) + }); + } + }); + } + if (isTimeline) { + if (!isRemove) { + timeline.push( + { + created_at: new Date(), + index: currPostId, + statuses: [ + { + status: 'Created', + timestamp: new Date() + } + ], + type: 'Discussions' + } + ); + } + timeline.push(...getTimeline(proposals)); + if (timeline.length <= 1) { + timeline.push(getTimeline([ + { + createdAt: post?.createdAt, + hash: post?.hash, + index: post?.index, + statusHistory: post?.statusHistory, + type: post?.type + } + ])); + } + } + if (postsRefWithData.length === 0) { + postsRefWithData.push({ + data: { + id: (postType === 'tips'? postId: Number(postId)) + }, + ref: postsByTypeRef(network, postType as any).doc(String(postId)) + }); + } + const results = await firestore_db.getAll(...postsRefWithData.map((v) => (v.ref))); + results.forEach((result, i) => { + if (result && result.exists) { + const data = result.data(); + const newData: any = { + ...data, + last_edited_at: new Date(), + post_link: isRemove? null: post_link + }; + if (!newData.user_id && newData.user_id !== 0) { + newData.user_id = user.id; + } + if (!newData.id && newData.id !== 0) { + newData.id = postsRefWithData?.[i]?.data?.id; + } + if (!newData.username) { + newData.username = user.username; + } + if (!newData.proposer_address) { + newData.proposer_address = substrateAddress; + } + if (!newData.created_at) { + newData.created_at = new Date(); + } + if (!newData.topic_id && newData.topic_id !== 0) { + const topic = newData.topic; + if (topic && topic.name) { + newData.topic_id = topic.id; + delete newData.topic; + } else { + newData.topic_id = currPostData.topic_id; + } + } + if (postsRefWithData[i]) { + postsRefWithData[i].data = newData; + } + } + }); + postsRefWithData.forEach((obj) => { + if (obj) { + const { data, ref } = obj; + if (data && ref) { + batch.set(ref, data, { merge: true }); + } + } + }); + await batch.commit(); + return { + timeline + }; +}; + +interface IGetPostsRefAndDataParams { + network: string; + posts: { + id: string | number; + type: string; + isExistChecked: boolean; + }[]; +} +type TPostsRefWithData = { + data?: any, + ref: FirebaseFirestore.DocumentReference +}[]; +type TGetPostsRefAndData = (params: IGetPostsRefAndDataParams) => Promise; +export const getPostsRefAndData: TGetPostsRefAndData = async (params) => { + const { network, posts } = params; + + const postsRefWithData: TPostsRefWithData = posts.map((post) => { + return { + data: {}, + ref: postsByTypeRef(network, post.type as any).doc(String(post.id)) + }; + }); + const results = await firestore_db.getAll(...postsRefWithData.map(({ ref }) => (ref))); + + results.forEach((result, i) => { + const currPostData = result.data(); + if (posts?.[i].isExistChecked && !(result.exists && currPostData)) { + throw apiErrorWithStatusCode(`Post with id: "${posts[i].id}" and type: "${posts[i].type}" does not exist.`, 404); + } + postsRefWithData[i].data = currPostData; + }); + return postsRefWithData; +}; + +export interface ILinkPostConfirmResponse { + timeline: any[]; +} + +const handler: NextApiHandler = async (req, res) => { + if (req.method !== 'POST') return res.status(405).json({ message: 'Invalid request method, POST required.' }); + + const network = String(req.headers['x-network']); + if(!network || !isValidNetwork(network)) return res.status(400).json({ message: 'Invalid network in request header' }); + + const { postId, postType, currPostId, currPostType } = req.body; + + if((!postId && postId !== 0) || (!currPostId && currPostId !== 0) || !postType || !currPostType) return res.status(400).json({ message: 'Missing parameters in request body' }); + + const token = getTokenFromReq(req); + if(!token) return res.status(400).json({ message: 'Invalid token' }); + + const user = await authServiceInstance.GetUser(token); + if(!user) return res.status(403).json({ message: messages.UNAUTHORISED }); + + try { + [postType, currPostType].filter((type) => { + const strType = String(type) as ProposalType; + const isOffChainPost = isOffChainProposalTypeValid(strType); + const isOnChainPost = isProposalTypeValid(strType); + + if (!isOffChainPost && !isOnChainPost) { + throw apiErrorWithStatusCode(`The post type of the name "${type}" does not exist.`, 400); + } + }); + const postsRefWithData = await getPostsRefAndData({ + network, + posts: [ + { + id: currPostId, + isExistChecked: true, + type: currPostType + }, + { + id: postId, + isExistChecked: false, + type: postType + } + ] + }); + if (postsRefWithData.length !== 2) { + throw apiErrorWithStatusCode('Something went wrong!', 500); + } + const [{ data: currPostData, ref: currPostDocRef }, { data: postData, ref: postDocRef }] = postsRefWithData; + let params = { + currPostData, + currPostDocRef, + currPostId, + currPostType, + isTimeline: true, + network, + postId, + postType, + user + }; + if (isOffChainProposalTypeValid(String(postType))) { + if (!postData) { + throw apiErrorWithStatusCode(`Post with id: "${postId}" and type: "${postType}" does not exist, please create a post.`, 404); + } + const isAuthor = user.id === postData.user_id; + if (!isAuthor) { + throw apiErrorWithStatusCode('You can not link the post, because you are not the user who created this post.', 403); + } + params = { + currPostData: postData, + currPostDocRef: postDocRef, + currPostId: postId, + currPostType: postType, + isTimeline: true, + network, + postId: currPostId, + postType: currPostType, + user + }; + } + + const data = await updatePostLinkInGroup(params); + return res.status(200).json(data); + } catch (error) { + return res.status(error.name).json({ message: error.message }); + } +}; + +export default withErrorHandling(handler); diff --git a/pages/api/v1/auth/actions/linkPostRemove.ts b/pages/api/v1/auth/actions/linkPostRemove.ts new file mode 100644 index 0000000000..990ac33638 --- /dev/null +++ b/pages/api/v1/auth/actions/linkPostRemove.ts @@ -0,0 +1,108 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import { NextApiHandler } from 'next'; + +import withErrorHandling from '~src/api-middlewares/withErrorHandling'; +import { isOffChainProposalTypeValid, isProposalTypeValid, isValidNetwork } from '~src/api-utils'; +import authServiceInstance from '~src/auth/auth'; +import { MessageType } from '~src/auth/types'; +import getTokenFromReq from '~src/auth/utils/getTokenFromReq'; +import messages from '~src/auth/utils/messages'; +import { ProposalType } from '~src/global/proposalType'; +import apiErrorWithStatusCode from '~src/util/apiErrorWithStatusCode'; +import { getPostsRefAndData, updatePostLinkInGroup } from './linkPostConfirm'; + +export interface ILinkPostRemoveResponse { + timeline: any[]; +} + +const handler: NextApiHandler = async (req, res) => { + if (req.method !== 'POST') return res.status(405).json({ message: 'Invalid request method, POST required.' }); + + const network = String(req.headers['x-network']); + if(!network || !isValidNetwork(network)) return res.status(400).json({ message: 'Invalid network in request header' }); + + const { postId, postType, currPostId, currPostType } = req.body; + + if((!postId && postId !== 0) || (!currPostId && currPostId !== 0) || !postType || !currPostType) return res.status(400).json({ message: 'Missing parameters in request body' }); + + const token = getTokenFromReq(req); + if(!token) return res.status(400).json({ message: 'Invalid token' }); + + const user = await authServiceInstance.GetUser(token); + if(!user) return res.status(403).json({ message: messages.UNAUTHORISED }); + + try { + [postType, currPostType].filter((type) => { + const strType = String(type) as ProposalType; + const isOffChainPost = isOffChainProposalTypeValid(strType); + const isOnChainPost = isProposalTypeValid(strType); + + if (!isOffChainPost && !isOnChainPost) { + throw apiErrorWithStatusCode(`The post type of the name "${type}" does not exist.`, 400); + } + }); + const postsRefWithData = await getPostsRefAndData({ + network, + posts: [ + { + id: currPostId, + isExistChecked: true, + type: currPostType + }, + { + id: postId, + isExistChecked: false, + type: postType + } + ] + }); + if (postsRefWithData.length !== 2) { + throw apiErrorWithStatusCode('Something went wrong!', 500); + } + const [{ data: currPostData, ref: currPostDocRef }, { data: postData, ref: postDocRef }] = postsRefWithData; + let params = { + currPostData, + currPostDocRef, + currPostId, + currPostType, + isRemove: true, + isTimeline: false, + network, + postId, + postType, + user + }; + if (isOffChainProposalTypeValid(String(postType))) { + if (!postData) { + throw apiErrorWithStatusCode(`Post with id: "${postId}" and type: "${postType}" does not exist.`, 404); + } + const isAuthor = user.id === postData.user_id; + if (!isAuthor) { + throw apiErrorWithStatusCode('You can not unlink the post, because you are not the user who created this post.', 403); + } + params = { + currPostData: postData, + currPostDocRef: postDocRef, + currPostId: postId, + currPostType: postType, + isRemove: true, + isTimeline: true, + network, + postId: currPostId, + postType: currPostType, + user + }; + } + + const data = await updatePostLinkInGroup(params); + return res.status(200).json(data); + } catch (error) { + return res.status(error.name).json({ message: error.message }); + } + +}; + +export default withErrorHandling(handler); diff --git a/pages/api/v1/auth/actions/linkPostStart.ts b/pages/api/v1/auth/actions/linkPostStart.ts new file mode 100644 index 0000000000..aba9a4460d --- /dev/null +++ b/pages/api/v1/auth/actions/linkPostStart.ts @@ -0,0 +1,121 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import { NextApiHandler } from 'next'; + +import withErrorHandling from '~src/api-middlewares/withErrorHandling'; +import { isOffChainProposalTypeValid, isProposalTypeValid, isValidNetwork } from '~src/api-utils'; +import { postsByTypeRef } from '~src/api-utils/firestore_refs'; +import authServiceInstance from '~src/auth/auth'; +import { MessageType } from '~src/auth/types'; +import getAddressesFromUserId from '~src/auth/utils/getAddressesFromUserId'; +import getTokenFromReq from '~src/auth/utils/getTokenFromReq'; +import messages from '~src/auth/utils/messages'; +import { getSubsquidProposalType, ProposalType } from '~src/global/proposalType'; +import { GET_PROPOSAL_BY_INDEX_AND_TYPE_FOR_LINKING } from '~src/queries'; +import fetchSubsquid from '~src/util/fetchSubsquid'; +import getSubstrateAddress from '~src/util/getSubstrateAddress'; +import { isDataExist } from '../../posts/on-chain-post'; + +export interface ILinkPostStartResponse { + title: string; + description: string; +} + +const handler: NextApiHandler = async (req, res) => { + if (req.method !== 'POST') return res.status(405).json({ message: 'Invalid request method, POST required.' }); + + const network = String(req.headers['x-network']); + if(!network || !isValidNetwork(network)) return res.status(400).json({ message: 'Invalid network in request header' }); + + const { postId, postType } = req.body; + + if((!postId && postId !== 0) || !postType) return res.status(400).json({ message: 'Missing parameters in request body' }); + + const token = getTokenFromReq(req); + if(!token) return res.status(400).json({ message: 'Invalid token' }); + + const user = await authServiceInstance.GetUser(token); + if(!user) return res.status(403).json({ message: messages.UNAUTHORISED }); + + const strProposalType = String(postType) as ProposalType; + const isOffChainPost = isOffChainProposalTypeValid(strProposalType); + const isOnChainPost = isProposalTypeValid(strProposalType); + if (!isOffChainPost && !isOnChainPost) return res.status(400).json({ message: `The post type of the name "${postType}" does not exist.` }); + + const linkPostRes: ILinkPostStartResponse = { + description: '', + title: '' + }; + const postDocRef = postsByTypeRef(network, strProposalType).doc(String(postId)); + const postDoc = await postDocRef.get(); + const postData = postDoc.data(); + const isPostExist = postDoc.exists && postData; + if (isOffChainPost) { + if (!isPostExist) { + return res.status(404).json({ message: `Post with id: "${postId}" and type: "${postType}" does not exist, please create a post.` }); + } + const isAuthor = user.id === postData.user_id; + if (!isAuthor) { + return res.status(403).json({ message: 'You can not link the post, because you are not the user who created this post.' }); + } + linkPostRes.title = postData?.title; + linkPostRes.description = postData?.content; + } else { + const subsquidProposalType = getSubsquidProposalType(strProposalType as any); + + const variables: any = { + type_eq: subsquidProposalType + }; + + if (strProposalType === ProposalType.TIPS) { + variables['hash_eq'] = String(postId); + } else { + variables['index_eq'] = Number(postId); + } + const subsquidRes = await fetchSubsquid({ + network, + query: GET_PROPOSAL_BY_INDEX_AND_TYPE_FOR_LINKING, + variables: variables + }); + + // Subsquid Data + const subsquidData = subsquidRes?.data; + if (!isDataExist(subsquidData)) { + return res.status(400).json({ message: `The Post with index "${postId}" is not found.` }); + } + const post = subsquidData.proposals[0]; + const preimage = post?.preimage; + if(!post || (!post?.proposer && !preimage?.proposer)) return res.status(500).json({ message: 'Proposer address is not present in subsquid response.' }); + + const proposerAddress = post.proposer || post.preimage?.proposer; + + const substrateAddress = getSubstrateAddress(proposerAddress); + if(!substrateAddress) return res.status(500).json({ message: 'Something went wrong while getting encoded address corresponding to network' }); + + const userAddresses = await getAddressesFromUserId(user.id, true); + const isAuthor = userAddresses.some(address => address.address === substrateAddress) || (isPostExist && user.id === postData.user_id); + if (!isAuthor) { + return res.status(403).json({ message: 'You can not link the post, because you are not the user who created this post.' }); + } + if (isPostExist) { + if (postData?.title) { + linkPostRes.title = postData?.title; + } + if (postData?.content) { + linkPostRes.description = postData?.content; + } + } + if (!linkPostRes.title) { + linkPostRes.title = preimage?.method; + } + if (!linkPostRes.description) { + linkPostRes.description = post.description || preimage?.proposedCall?.description; + } + } + + return res.status(200).json(linkPostRes); +}; + +export default withErrorHandling(handler); diff --git a/pages/api/v1/auth/actions/linkProxyAddress.ts b/pages/api/v1/auth/actions/linkProxyAddress.ts new file mode 100644 index 0000000000..14949439ce --- /dev/null +++ b/pages/api/v1/auth/actions/linkProxyAddress.ts @@ -0,0 +1,30 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import { NextApiRequest, NextApiResponse } from 'next'; + +import withErrorHandling from '~src/api-middlewares/withErrorHandling'; +import authServiceInstance from '~src/auth/auth'; +import { ChangeResponseType, MessageType } from '~src/auth/types'; +import getTokenFromReq from '~src/auth/utils/getTokenFromReq'; + +async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== 'POST') return res.status(405).json({ message: 'Invalid request method, POST required.' }); + + const network = String(req.headers['x-network']); + if(!network) return res.status(400).json({ message: 'Missing network in request header' }); + + const { proxied, proxy, message, signature } = req.body; + + if(!proxied || !proxy || !message || !signature) res.status(400).json({ message: 'Missing parameters in request body' }); + + const token = getTokenFromReq(req); + if(!token) return res.status(400).json({ message: 'Invalid token' }); + + const updatedJWT = await authServiceInstance.LinkProxyAddress(token, network, proxied, proxy, message, signature); + + return res.status(200).json({ message: 'Proxied address linked', token: updatedJWT }); +} + +export default withErrorHandling(handler); diff --git a/pages/api/v1/auth/actions/login.ts b/pages/api/v1/auth/actions/login.ts new file mode 100644 index 0000000000..b70ff1dfcd --- /dev/null +++ b/pages/api/v1/auth/actions/login.ts @@ -0,0 +1,24 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import { NextApiRequest, NextApiResponse } from 'next'; + +import withErrorHandling from '~src/api-middlewares/withErrorHandling'; +import authServiceInstance from '~src/auth/auth'; +import { MessageType, TokenType } from '~src/auth/types'; + +async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== 'POST') return res.status(405).json({ message: 'Invalid request method, POST required.' }); + const body = req.body; + + const { username, password } = body; + + if(!body || !username || !password) return res.status(400).json({ message: 'Missing parameters in request body' }); + + const { token } = await authServiceInstance.Login(username, password); + + return res.status(200).json({ token }); +} + +export default withErrorHandling(handler); diff --git a/pages/api/v1/auth/actions/multisigLinkConfirm.ts b/pages/api/v1/auth/actions/multisigLinkConfirm.ts new file mode 100644 index 0000000000..1746959c2c --- /dev/null +++ b/pages/api/v1/auth/actions/multisigLinkConfirm.ts @@ -0,0 +1,38 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import { NextApiRequest, NextApiResponse } from 'next'; + +import withErrorHandling from '~src/api-middlewares/withErrorHandling'; +import authServiceInstance from '~src/auth/auth'; +import { ChangeResponseType, MessageType } from '~src/auth/types'; +import getTokenFromReq from '~src/auth/utils/getTokenFromReq'; +import messages from '~src/auth/utils/messages'; + +async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== 'POST') return res.status(405).json({ message: 'Invalid request method, POST required.' }); + const network = String(req.headers['x-network']); + if(!network) return res.status(400).json({ message: 'Missing network in request header' }); + + const { address, addresses, ss58Prefix, threshold, signatory, signature } = req.body; + if(!address || !addresses || !ss58Prefix || !threshold || ! signatory || !signature) res.status(400).json({ message: 'Missing parameters in request body' }); + + const token = getTokenFromReq(req); + if(!token) return res.status(400).json({ message: 'Invalid token' }); + + const updatedJWT = await authServiceInstance.MultiSigAddressLinkConfirm( + token, + network, + address, + addresses, + ss58Prefix, + threshold, + signatory, + signature + ); + + return res.status(200).json({ message: messages.ADDRESS_LINKING_SUCCESSFUL, token: updatedJWT }); +} + +export default withErrorHandling(handler); diff --git a/pages/api/v1/auth/actions/multisigLinkStart.ts b/pages/api/v1/auth/actions/multisigLinkStart.ts new file mode 100644 index 0000000000..fca3b0d3d7 --- /dev/null +++ b/pages/api/v1/auth/actions/multisigLinkStart.ts @@ -0,0 +1,23 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import { NextApiRequest, NextApiResponse } from 'next'; + +import withErrorHandling from '~src/api-middlewares/withErrorHandling'; +import authServiceInstance from '~src/auth/auth'; +import { ChallengeMessage, MessageType } from '~src/auth/types'; +import messages from '~src/auth/utils/messages'; + +async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== 'POST') return res.status(405).json({ message: 'Invalid request method, POST required.' }); + + const { address } = req.body; + if(!address) return res.status(400).json({ message: 'Missing parameters in request body' }); + + const signMessage = await authServiceInstance.MultisigAddressSignupStart(address); + + return res.status(200).json({ message: messages.ADDRESS_LINKING_STARTED, signMessage }); +} + +export default withErrorHandling(handler); diff --git a/pages/api/v1/auth/actions/postSubscribe.ts b/pages/api/v1/auth/actions/postSubscribe.ts new file mode 100644 index 0000000000..299c9776dc --- /dev/null +++ b/pages/api/v1/auth/actions/postSubscribe.ts @@ -0,0 +1,86 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import { NextApiRequest, NextApiResponse } from 'next'; + +import withErrorHandling from '~src/api-middlewares/withErrorHandling'; +import { isFirestoreProposalTypeValid, isValidNetwork } from '~src/api-utils'; +import { networkDocRef } from '~src/api-utils/firestore_refs'; +import authServiceInstance, { NOTIFICATION_DEFAULTS } from '~src/auth/auth'; +import { ChangeResponseType, IUserPreference, MessageType } from '~src/auth/types'; +import getTokenFromReq from '~src/auth/utils/getTokenFromReq'; +import messages from '~src/auth/utils/messages'; +import { offChainProposalTypes } from '~src/global/proposalType'; + +async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== 'POST') return res.status(405).json({ message: 'Invalid request method, POST required.' }); + + const network = String(req.headers['x-network']); + if(!network || !isValidNetwork(network)) res.status(400).json({ message: 'Invalid network in request header' }); + + const { post_id, proposalType } = req.body; + + const strProposalType = String(proposalType); + if (!isFirestoreProposalTypeValid(strProposalType)) { + return res.status(400).json({ message: `The proposal type "${proposalType}" is invalid.` }); + } + if(!post_id) return res.status(400).json({ message: 'Missing parameters in request body' }); + + const token = getTokenFromReq(req); + if(!token) return res.status(400).json({ message: 'Invalid token' }); + + const user = await authServiceInstance.GetUser(token); + + if(!user) return res.status(400).json({ message: messages.USER_NOT_FOUND }); + if (!user.email_verified) return res.status(400).json({ message: messages.SUBSCRIPTION_EMAIL_UNVERIFIED }); + + const userPreferenceRef = networkDocRef(network).collection('user_preferences').doc(String(user.id)); + const userPreference = await userPreferenceRef.get(); + + const strPostId = String(post_id); + if (!userPreference.exists) { + userPreferenceRef.set({ + notification_settings: NOTIFICATION_DEFAULTS, + post_subscriptions: { + [strProposalType]: [strPostId] + }, + user_id: user.id + }).then(() => { + return res.status(200).json({ message: messages.SUBSCRIPTION_SUCCESSFUL }); + }).catch((error) => { + console.log(' Error while adding subscription : ', error); + return res.status(400).json({ message: 'Error while adding subscription.' }); + }); + } else { + const data = userPreference.data() as IUserPreference; + + const notification_settings = data?.notification_settings; + if (offChainProposalTypes.includes(strProposalType) && !notification_settings?.post_created) { + return res.status(400).json({ message: 'Restricted subscribe to the post because of "Subscribe to post you created" is off.' }); + } + if (!notification_settings?.post_participated) { + return res.status(400).json({ message: 'Restricted subscribe to the post because of "Subscribe to post you participate in" is off.' }); + } + + const post_subscriptions = data?.post_subscriptions; + const proposalTypePostSubscriptions = post_subscriptions[strProposalType as keyof typeof data.post_subscriptions] || []; + if(proposalTypePostSubscriptions?.some((id) => String(id) === strPostId)) return res.status(400).json({ message: messages.SUBSCRIPTION_ALREADY_EXISTS }); + + proposalTypePostSubscriptions.push(strPostId); + userPreferenceRef.update({ + post_subscriptions: { + ...post_subscriptions, + [strProposalType]: proposalTypePostSubscriptions + } + }).then(() => { + return res.status(200).json({ message: messages.SUBSCRIPTION_SUCCESSFUL }); + }).catch((error) => { + console.log(' Error while adding subscription : ', error); + return res.status(400).json({ message: 'Error while adding subscription.' }); + }); + } + +} + +export default withErrorHandling(handler); diff --git a/pages/api/v1/auth/actions/postUnsubscribe.ts b/pages/api/v1/auth/actions/postUnsubscribe.ts new file mode 100644 index 0000000000..eee8128cdc --- /dev/null +++ b/pages/api/v1/auth/actions/postUnsubscribe.ts @@ -0,0 +1,77 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import { NextApiRequest, NextApiResponse } from 'next'; + +import withErrorHandling from '~src/api-middlewares/withErrorHandling'; +import { isFirestoreProposalTypeValid, isValidNetwork } from '~src/api-utils'; +import { networkDocRef } from '~src/api-utils/firestore_refs'; +import authServiceInstance, { NOTIFICATION_DEFAULTS } from '~src/auth/auth'; +import { ChangeResponseType, IUserPreference, MessageType } from '~src/auth/types'; +import getTokenFromReq from '~src/auth/utils/getTokenFromReq'; +import messages from '~src/auth/utils/messages'; + +async function handler(req: NextApiRequest, res: NextApiResponse) { + + if (req.method !== 'POST') return res.status(405).json({ message: 'Invalid request method, POST required.' }); + + const network = String(req.headers['x-network']); + if(!network || !isValidNetwork(network)) res.status(400).json({ message: 'Invalid network in request header' }); + + const { post_id, proposalType } = req.body; + + const strProposalType = String(proposalType); + if (!isFirestoreProposalTypeValid(strProposalType)) { + return res.status(400).json({ message: `The proposal type "${proposalType}" is invalid.` }); + } + if(!post_id) return res.status(400).json({ message: 'Missing parameters in request body' }); + + const token = getTokenFromReq(req); + if(!token) return res.status(400).json({ message: 'Invalid token' }); + + const user = await authServiceInstance.GetUser(token); + if(!user) return res.status(400).json({ message: messages.USER_NOT_FOUND }); + + if (!user.email_verified) return res.status(400).json({ message: messages.SUBSCRIPTION_EMAIL_UNVERIFIED }); + + const userPreferenceRef = networkDocRef(network).collection('user_preferences').doc(String(user.id)); + const userPreference = await userPreferenceRef.get(); + + const strPostId = String(post_id); + if (!userPreference.exists) { + userPreferenceRef.set({ + notification_settings: NOTIFICATION_DEFAULTS, + post_subscriptions: { + [strProposalType]: [] + }, + user_id: user.id + }).then(() => { + return res.status(200).json({ message: messages.SUBSCRIPTION_REMOVE_SUCCESSFUL }); + }).catch((error) => { + console.log(' Error while adding subscription : ', error); + return res.status(400).json({ message: 'Error while adding subscription.' }); + }); + } else { + const data = userPreference.data() as IUserPreference; + const post_subscriptions = data?.post_subscriptions; + const proposalTypePostSubscriptions = post_subscriptions[strProposalType as keyof typeof data.post_subscriptions] || []; + const index = proposalTypePostSubscriptions.findIndex((id) => String(id) === strPostId); + if(index < 0)return res.status(400).json({ message: messages.SUBSCRIPTION_DOES_NOT_EXIST }); + + proposalTypePostSubscriptions.push(strPostId); + userPreferenceRef.update({ + post_subscriptions: { + ...post_subscriptions, + [strProposalType]: proposalTypePostSubscriptions.splice(index, 1) + } + }).then(() => { + return res.status(200).json({ message: messages.SUBSCRIPTION_REMOVE_SUCCESSFUL }); + }).catch((error) => { + console.log(' Error while adding subscription : ', error); + return res.status(400).json({ message: 'Error while adding subscription.' }); + }); + } +} + +export default withErrorHandling(handler); diff --git a/pages/api/v1/auth/actions/removeCommentReaction.ts b/pages/api/v1/auth/actions/removeCommentReaction.ts new file mode 100644 index 0000000000..b044e64170 --- /dev/null +++ b/pages/api/v1/auth/actions/removeCommentReaction.ts @@ -0,0 +1,49 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import { NextApiRequest, NextApiResponse } from 'next'; + +import withErrorHandling from '~src/api-middlewares/withErrorHandling'; +import { postsByTypeRef } from '~src/api-utils/firestore_refs'; +import authServiceInstance from '~src/auth/auth'; +import { MessageType } from '~src/auth/types'; +import getTokenFromReq from '~src/auth/utils/getTokenFromReq'; +import messages from '~src/auth/utils/messages'; + +async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== 'POST') return res.status(405).json({ message: 'Invalid request method, POST required.' }); + + const network = String(req.headers['x-network']); + if(!network) return res.status(400).json({ message: 'Missing network name in request headers' }); + + const { userId, postId, commentId, reaction, postType } = req.body; + if(!userId || isNaN(postId) || !commentId || !reaction || !postType) return res.status(400).json({ message: 'Missing parameters in request body' }); + + const token = getTokenFromReq(req); + if(!token) return res.status(400).json({ message: 'Invalid token' }); + + const user = await authServiceInstance.GetUser(token); + if(!user || user.id !== Number(userId)) return res.status(403).json({ message: messages.UNAUTHORISED }); + + const postRef = postsByTypeRef(network, postType).doc(String(postId)); + const userReactionsSnapshot = await postRef + .collection('comments') + .doc(String(commentId)) + .collection('comment_reactions') + .where('user_id', '==', user.id).limit(1).get(); + + if(!userReactionsSnapshot.empty) { + const reactionDocRef = userReactionsSnapshot.docs[0].ref; + await reactionDocRef.delete().then(() => { + return res.status(200).json({ message: 'Reaction removed.' }); + }).catch((error) => { + console.error('Error removing reaction: ', error); + return res.status(500).json({ message: 'Error removing reaction' }); + }); + }else { + res.status(400).json({ message: 'No reaction found' }); + } +} + +export default withErrorHandling(handler); diff --git a/pages/api/v1/auth/actions/removePostReaction.ts b/pages/api/v1/auth/actions/removePostReaction.ts new file mode 100644 index 0000000000..8f7a66d334 --- /dev/null +++ b/pages/api/v1/auth/actions/removePostReaction.ts @@ -0,0 +1,47 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import { NextApiRequest, NextApiResponse } from 'next'; + +import withErrorHandling from '~src/api-middlewares/withErrorHandling'; +import { postsByTypeRef } from '~src/api-utils/firestore_refs'; +import authServiceInstance from '~src/auth/auth'; +import { MessageType } from '~src/auth/types'; +import getTokenFromReq from '~src/auth/utils/getTokenFromReq'; +import messages from '~src/auth/utils/messages'; + +async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== 'POST') return res.status(405).json({ message: 'Invalid request method, POST required.' }); + + const network = String(req.headers['x-network']); + if(!network) return res.status(400).json({ message: 'Missing network name in request headers' }); + + const { userId, postId, reaction, postType } = req.body; + if(!userId || isNaN(postId) || !reaction || !postType) return res.status(400).json({ message: 'Missing parameters in request body' }); + + const token = getTokenFromReq(req); + if(!token) return res.status(400).json({ message: 'Invalid token' }); + + const user = await authServiceInstance.GetUser(token); + if(!user || user.id !== Number(userId)) return res.status(403).json({ message: messages.UNAUTHORISED }); + + const postRef = postsByTypeRef(network, postType).doc(String(postId)); + const userReactionsSnapshot = await postRef + .collection('post_reactions') + .where('user_id', '==', user.id).limit(1).get(); + + if(!userReactionsSnapshot.empty) { + const reactionDocRef = userReactionsSnapshot.docs[0].ref; + await reactionDocRef.delete().then(() => { + return res.status(200).json({ message: 'Reaction removed.' }); + }).catch((error) => { + console.error('Error removing reaction: ', error); + return res.status(500).json({ message: 'Error removing reaction' }); + }); + }else { + res.status(400).json({ message: 'No reaction found' }); + } +} + +export default withErrorHandling(handler); diff --git a/pages/api/v1/auth/actions/reportContent.ts b/pages/api/v1/auth/actions/reportContent.ts new file mode 100644 index 0000000000..30aedd7066 --- /dev/null +++ b/pages/api/v1/auth/actions/reportContent.ts @@ -0,0 +1,51 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import { NextApiRequest, NextApiResponse } from 'next'; + +import withErrorHandling from '~src/api-middlewares/withErrorHandling'; +import authServiceInstance from '~src/auth/auth'; +import { ChangeResponseType, MessageType } from '~src/auth/types'; +import getTokenFromReq from '~src/auth/utils/getTokenFromReq'; +import messages from '~src/auth/utils/messages'; +import firebaseAdmin from '~src/services/firebaseInit'; + +async function handler(req: NextApiRequest, res: NextApiResponse) { + const firestore = firebaseAdmin.firestore(); + + if (req.method !== 'POST') return res.status(405).json({ message: 'Invalid request method, POST required.' }); + + const network = String(req.headers['x-network']); + if(!network) return res.status(400).json({ message: 'Missing network in request header' }); + + const { type, content_id, reason, comments } = req.body; + if(!type || !content_id || !reason || !comments) return res.status(400).json({ message: 'Missing parameters in request body' }); + + const token = getTokenFromReq(req); + if(!token) return res.status(400).json({ message: 'Invalid token' }); + + const user = await authServiceInstance.GetUser(token); + if(!user) return res.status(400).json({ message: messages.USER_NOT_FOUND }); + + if (!['post', 'comment'].includes(type)) return res.status(400).json({ message: messages.REPORT_TYPE_INVALID }); + if (!reason) return res.status(400).json({ message: messages.REPORT_REASON_REQUIRED }); + if (comments.length > 300) return res.status(400).json({ message: messages.REPORT_COMMENTS_LENGTH_EXCEDEED }); + + await firestore.collection('networks').doc(network).collection('reports').add({ + comments, + content_id, + reason, + resolved: false, + type, + user_id: user.id + }).then(() => { + return res.status(200).json({ message: messages.CONTENT_REPORT_SUCCESSFUL }); + }).catch((error) => { + console.log(' Error while reporting content : ', error); + return res.status(500).json({ message: 'Error while reporting content.' }); + }); + +} + +export default withErrorHandling(handler); diff --git a/pages/api/v1/auth/actions/requestResetPassword.ts b/pages/api/v1/auth/actions/requestResetPassword.ts new file mode 100644 index 0000000000..80550f94c7 --- /dev/null +++ b/pages/api/v1/auth/actions/requestResetPassword.ts @@ -0,0 +1,31 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import { NextApiRequest, NextApiResponse } from 'next'; + +import withErrorHandling from '~src/api-middlewares/withErrorHandling'; +import authServiceInstance from '~src/auth/auth'; +import { MessageType } from '~src/auth/types'; +import { isValidNetwork } from '~src/api-utils'; +import messages from '~src/auth/utils/messages'; + +async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== 'POST') return res.status(405).json({ message: 'Invalid request method, POST required.' }); + + const network = String(req.headers['x-network']); + if(!network || !isValidNetwork(network)) res.status(400).json({ message: 'Invalid network in request header' }); + + const { email } = req.body; + + if(!email) return res.status(400).json({ message: 'Missing parameters in request body' }); + + const err = await authServiceInstance.RequestResetPassword(email, network); + if (err) { + return res.status(403).json({ message: err }); + } + + return res.status(200).json({ message: messages.RESET_PASSWORD_RETURN_MESSAGE }); +} + +export default withErrorHandling(handler); diff --git a/pages/api/v1/auth/actions/resendVerifyEmailToken.ts b/pages/api/v1/auth/actions/resendVerifyEmailToken.ts new file mode 100644 index 0000000000..4968bdb38b --- /dev/null +++ b/pages/api/v1/auth/actions/resendVerifyEmailToken.ts @@ -0,0 +1,28 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import { NextApiRequest, NextApiResponse } from 'next'; + +import withErrorHandling from '~src/api-middlewares/withErrorHandling'; +import { isValidNetwork } from '~src/api-utils'; +import authServiceInstance from '~src/auth/auth'; +import { MessageType } from '~src/auth/types'; +import getTokenFromReq from '~src/auth/utils/getTokenFromReq'; +import messages from '~src/auth/utils/messages'; + +async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== 'POST') return res.status(405).json({ message: 'Invalid request method, POST required.' }); + + const network = String(req.headers['x-network']); + if(!network || !isValidNetwork(network)) res.status(400).json({ message: 'Invalid network in request header' }); + + const token = getTokenFromReq(req); + if(!token) return res.status(400).json({ message: 'Invalid token' }); + + await authServiceInstance.resendVerifyEmailToken(token, network); + + return res.status(200).json({ message: messages.RESEND_VERIFY_EMAIL_TOKEN_REQUEST_SUCCESSFUL }); +} + +export default withErrorHandling(handler); diff --git a/pages/api/v1/auth/actions/resetPassword.ts b/pages/api/v1/auth/actions/resetPassword.ts new file mode 100644 index 0000000000..2ba0c74828 --- /dev/null +++ b/pages/api/v1/auth/actions/resetPassword.ts @@ -0,0 +1,31 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import { NextApiRequest, NextApiResponse } from 'next'; + +import withErrorHandling from '~src/api-middlewares/withErrorHandling'; +import authServiceInstance from '~src/auth/auth'; +import { MessageType } from '~src/auth/types'; +import getTokenFromReq from '~src/auth/utils/getTokenFromReq'; +import isValidPassowrd from '~src/auth/utils/isValidPassowrd'; +import messages from '~src/auth/utils/messages'; + +async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== 'POST') return res.status(405).json({ message: 'Invalid request method, POST required.' }); + + const token = getTokenFromReq(req); + if(!token) return res.status(400).json({ message: 'Invalid token' }); + + const { userId, newPassword } = req.body; + + if(!userId || !newPassword) return res.status(400).json({ message: 'Missing parameters in request body' }); + + if(!isValidPassowrd(newPassword)) return res.status(400).json({ message: messages.PASSWORD_LENGTH_ERROR }); + + await authServiceInstance.ResetPassword(token, userId, newPassword); + + return res.status(200).json({ message: messages.PASSWORD_RESET_SUCCESSFUL }); +} + +export default withErrorHandling(handler); diff --git a/pages/api/v1/auth/actions/setCredentialsConfirm.ts b/pages/api/v1/auth/actions/setCredentialsConfirm.ts new file mode 100644 index 0000000000..ccda8dda0f --- /dev/null +++ b/pages/api/v1/auth/actions/setCredentialsConfirm.ts @@ -0,0 +1,39 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import { NextApiRequest, NextApiResponse } from 'next'; + +import withErrorHandling from '~src/api-middlewares/withErrorHandling'; +import { isValidNetwork } from '~src/api-utils'; +import authServiceInstance from '~src/auth/auth'; +import { ChangeResponseType, MessageType } from '~src/auth/types'; +import getTokenFromReq from '~src/auth/utils/getTokenFromReq'; +import isValidEmail from '~src/auth/utils/isValidEmail'; +import isValidPassowrd from '~src/auth/utils/isValidPassowrd'; +import isValidUsername from '~src/auth/utils/isValidUsername'; +import messages from '~src/auth/utils/messages'; + +async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== 'POST') return res.status(405).json({ message: 'Invalid request method, POST required.' }); + + const network = String(req.headers['x-network']); + if(!network || !isValidNetwork(network)) res.status(400).json({ message: 'Invalid network in request header' }); + + const token = getTokenFromReq(req); + if(!token) return res.status(400).json({ message: 'Invalid token' }); + + const body = JSON.parse(req.body); + const { address, email, password, signature, username } = body; + if(!body || !address || !email || !password || !signature || !username) return res.status(400).json({ message: 'Missing parameters in request body' }); + + if(!isValidUsername(username)) return res.status(400).json({ message: messages.USERNAME_INVALID_ERROR }); + if(!isValidPassowrd(password)) return res.status(400).json({ message: messages.PASSWORD_LENGTH_ERROR }); + if(email && !isValidEmail(email)) return res.status(400).json({ message: messages.INVALID_EMAIL }); + + const updatedJWT = await authServiceInstance.SetCredentialsConfirm(address, email, password, signature, username, network); + + return res.status(200).json({ message: messages.CREDENTIALS_CHANGE_SUCCESSFUL, token: updatedJWT }); +} + +export default withErrorHandling(handler); diff --git a/pages/api/v1/auth/actions/setCredentialsStart.ts b/pages/api/v1/auth/actions/setCredentialsStart.ts new file mode 100644 index 0000000000..ad801bddab --- /dev/null +++ b/pages/api/v1/auth/actions/setCredentialsStart.ts @@ -0,0 +1,29 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import { NextApiRequest, NextApiResponse } from 'next'; + +import withErrorHandling from '~src/api-middlewares/withErrorHandling'; +import authServiceInstance from '~src/auth/auth'; +import { ChallengeMessage, MessageType } from '~src/auth/types'; +import getTokenFromReq from '~src/auth/utils/getTokenFromReq'; +import messages from '~src/auth/utils/messages'; + +async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== 'POST') return res.status(405).json({ message: 'Invalid request method, POST required.' }); + + const token = getTokenFromReq(req); + if(!token) return res.status(400).json({ message: 'Invalid token' }); + + const body = JSON.parse(req.body); + const { address } = body; + if(!body || !address) return res.status(400).json({ message: 'Missing parameters in request body' }); + + return res.status(200).json({ + message: messages.CREDENTIALS_CHANGE_SUCCESSFUL, + signMessage: await authServiceInstance.SetCredentialsStart(address) + }); +} + +export default withErrorHandling(handler); diff --git a/pages/api/v1/auth/actions/setDefaultAddress.ts b/pages/api/v1/auth/actions/setDefaultAddress.ts new file mode 100644 index 0000000000..90d4b5c915 --- /dev/null +++ b/pages/api/v1/auth/actions/setDefaultAddress.ts @@ -0,0 +1,28 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import { NextApiRequest, NextApiResponse } from 'next'; + +import withErrorHandling from '~src/api-middlewares/withErrorHandling'; +import authServiceInstance from '~src/auth/auth'; +import { ChangeResponseType, MessageType } from '~src/auth/types'; +import getTokenFromReq from '~src/auth/utils/getTokenFromReq'; +import messages from '~src/auth/utils/messages'; + +async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== 'POST') return res.status(405).json({ message: 'Invalid request method, POST required.' }); + + const token = getTokenFromReq(req); + if(!token) return res.status(400).json({ message: 'Invalid token' }); + + const { address } = req.body; + if(!address) return res.status(400).json({ message: 'Missing parameters in request body' }); + + return res.status(200).json({ + message: messages.ADDRESS_DEFAULT_SUCCESS, + token: await authServiceInstance.SetDefaultAddress(token, address) + }); +} + +export default withErrorHandling(handler); diff --git a/pages/api/v1/auth/actions/signup.ts b/pages/api/v1/auth/actions/signup.ts new file mode 100644 index 0000000000..c017578f1e --- /dev/null +++ b/pages/api/v1/auth/actions/signup.ts @@ -0,0 +1,33 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import { NextApiRequest, NextApiResponse } from 'next'; + +import withErrorHandling from '~src/api-middlewares/withErrorHandling'; +import { isValidNetwork } from '~src/api-utils'; +import authServiceInstance from '~src/auth/auth'; +import { MessageType, TokenType } from '~src/auth/types'; +import isValidEmail from '~src/auth/utils/isValidEmail'; +import isValidPassowrd from '~src/auth/utils/isValidPassowrd'; +import isValidUsername from '~src/auth/utils/isValidUsername'; +import messages from '~src/auth/utils/messages'; + +async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== 'POST') return res.status(405).json({ message: 'Invalid request method, POST required.' }); + + const network = String(req.headers['x-network']); + if(!network || !isValidNetwork(network)) res.status(400).json({ message: 'Invalid network in request header' }); + const { email, password, username } = req.body; + + if(!email || !username || !password) return res.status(400).json({ message: 'Missing parameters in request body' }); + if (email && !isValidEmail(email)) return res.status(400).json({ message: messages.INVALID_EMAIL }); + if(!isValidUsername(username)) return res.status(400).json({ message: messages.USERNAME_INVALID_ERROR }); + if(!isValidPassowrd(password)) return res.status(400).json({ message: messages.PASSWORD_LENGTH_ERROR }); + + const { token } = await authServiceInstance.SignUp(email, password, username, network); + + return res.status(200).json({ token }); +} + +export default withErrorHandling(handler); diff --git a/pages/api/v1/auth/actions/undoEmailChange.ts b/pages/api/v1/auth/actions/undoEmailChange.ts new file mode 100644 index 0000000000..99096d5329 --- /dev/null +++ b/pages/api/v1/auth/actions/undoEmailChange.ts @@ -0,0 +1,24 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import { NextApiRequest, NextApiResponse } from 'next'; + +import withErrorHandling from '~src/api-middlewares/withErrorHandling'; +import authServiceInstance from '~src/auth/auth'; +import { MessageType, UndoEmailChangeResponseType } from '~src/auth/types'; +import getTokenFromReq from '~src/auth/utils/getTokenFromReq'; +import messages from '~src/auth/utils/messages'; + +async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== 'POST') return res.status(405).json({ message: 'Invalid request method, POST required.' }); + + const token = getTokenFromReq(req); + if(!token) return res.status(400).json({ message: 'Invalid token' }); + + const { updatedToken, email } = await authServiceInstance.UndoEmailChange(token); + + return res.status(200).json({ email, message: messages.EMAIL_UNDO_SUCCESSFUL, token: updatedToken }); +} + +export default withErrorHandling(handler); diff --git a/pages/api/v1/auth/actions/updateApprovalStatus.ts b/pages/api/v1/auth/actions/updateApprovalStatus.ts new file mode 100644 index 0000000000..d0a29836d1 --- /dev/null +++ b/pages/api/v1/auth/actions/updateApprovalStatus.ts @@ -0,0 +1,43 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import { NextApiRequest, NextApiResponse } from 'next'; + +import withErrorHandling from '~src/api-middlewares/withErrorHandling'; +import authServiceInstance from '~src/auth/auth'; +import { MessageType } from '~src/auth/types'; +import getTokenFromReq from '~src/auth/utils/getTokenFromReq'; +import messages from '~src/auth/utils/messages'; +import firebaseAdmin from '~src/services/firebaseInit'; + +async function handler(req: NextApiRequest, res: NextApiResponse) { + const network = String(req.headers['x-network']); + if(!network) return res.status(400).json({ message: 'Missing network name in request headers' }); + + if (req.method !== 'POST') return res.status(405).json({ message: 'Invalid request method, POST required.' }); + + const { approval_status, eventId } = req.body; + if(!approval_status || !eventId ) return res.status(400).json({ message: 'Missing parameters in request body' }); + + const token = getTokenFromReq(req); + if(!token) return res.status(400).json({ message: 'Invalid token' }); + + const user = await authServiceInstance.GetUser(token); + if(!user || user.id !== Number(process.env.EVENT_BOT_USER_ID)) return res.status(403).json({ message: messages.UNAUTHORISED }); + const firestore = firebaseAdmin.firestore(); + + const eventRef = await firestore.collection('networks').doc(network).collection('events').doc(String(eventId)); + const eventDoc = await eventRef.get(); + if(!eventDoc.exists) return res.status(404).json({ message: 'Event not found' }); + + eventRef.update({ status: approval_status.toLowerCase() }).then(() => { + return res.status(200).json({ message: 'Event status updated.' }); + }).catch((error) => { + // The document probably doesn't exist. + console.error('Error updating event status: ', error); + return res.status(500).json({ message: 'Error updating event status' }); + }); +} + +export default withErrorHandling(handler); diff --git a/pages/api/v1/auth/actions/updateProposalTracker.ts b/pages/api/v1/auth/actions/updateProposalTracker.ts new file mode 100644 index 0000000000..aff8c698a1 --- /dev/null +++ b/pages/api/v1/auth/actions/updateProposalTracker.ts @@ -0,0 +1,26 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import { NextApiRequest, NextApiResponse } from 'next'; + +import authServiceInstance from '~src/auth/auth'; +import { ChallengeMessage, MessageType } from '~src/auth/types'; +import getTokenFromReq from '~src/auth/utils/getTokenFromReq'; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== 'POST') return res.status(405).json({ message: 'Invalid request method, POST required.' }); + + const network = String(req.headers['x-network']); + if(!network) return res.status(400).json({ message: 'Missing network name in request headers' }); + + const { id, status } = req.body; + + if(!id || !status) return res.status(400).json({ message: 'Missing parameters in request body' }); + + const token = getTokenFromReq(req); + + await authServiceInstance.ProposalTrackerUpdate(id, status, token); + + return { message: 'Status updated successfully' }; +} diff --git a/pages/api/v1/auth/actions/verifyEmail.ts b/pages/api/v1/auth/actions/verifyEmail.ts new file mode 100644 index 0000000000..a256ab4199 --- /dev/null +++ b/pages/api/v1/auth/actions/verifyEmail.ts @@ -0,0 +1,23 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import { NextApiRequest, NextApiResponse } from 'next'; + +import withErrorHandling from '~src/api-middlewares/withErrorHandling'; +import authServiceInstance from '~src/auth/auth'; +import { ChangeResponseType, MessageType } from '~src/auth/types'; +import getTokenFromReq from '~src/auth/utils/getTokenFromReq'; +import messages from '~src/auth/utils/messages'; + +async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== 'POST') return res.status(405).json({ message: 'Invalid request method, POST required.' }); + const token = getTokenFromReq(req); + if(!token) return res.status(400).json({ message: 'Invalid token' }); + + const updatedJWT = await authServiceInstance.VerifyEmail(token); + + return res.status(200).json({ message: messages.EMAIL_VERIFICATION_SUCCESSFUL, token: updatedJWT }); +} + +export default withErrorHandling(handler); diff --git a/pages/api/v1/auth/data/notificationPreference.ts b/pages/api/v1/auth/data/notificationPreference.ts new file mode 100644 index 0000000000..69af049d92 --- /dev/null +++ b/pages/api/v1/auth/data/notificationPreference.ts @@ -0,0 +1,28 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. +import { NextApiRequest, NextApiResponse } from 'next'; + +import withErrorHandling from '~src/api-middlewares/withErrorHandling'; +import { isValidNetwork } from '~src/api-utils'; +import authServiceInstance from '~src/auth/auth'; +import { MessageType, NotificationSettings } from '~src/auth/types'; +import getTokenFromReq from '~src/auth/utils/getTokenFromReq'; + +async function handler(req: NextApiRequest, res: NextApiResponse) { + + const network = String(req.headers['x-network']); + if(!network || !isValidNetwork(network)) res.status(400).json({ message: 'Invalid network in request header' }); + + const token = getTokenFromReq(req); + if(!token) return res.status(400).json({ message: 'Invalid token' }); + + try { + const notification_settings = await authServiceInstance.GetNotificationPreference(token, network); + return res.status(200).json(notification_settings); + } catch (error) { + return res.status(Number(error.name)).json({ message: error?.message }); + } +} + +export default withErrorHandling(handler); \ No newline at end of file diff --git a/pages/api/v1/auth/data/profileWithAddress.ts b/pages/api/v1/auth/data/profileWithAddress.ts new file mode 100644 index 0000000000..4eed236cda --- /dev/null +++ b/pages/api/v1/auth/data/profileWithAddress.ts @@ -0,0 +1,77 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. +import { NextApiRequest, NextApiResponse } from 'next'; + +import withErrorHandling from '~src/api-middlewares/withErrorHandling'; +import { MessageType, ProfileDetails, User } from '~src/auth/types'; +import { firestore_db } from '~src/services/firebaseInit'; +import { IApiResponse } from '~src/types'; +import apiErrorWithStatusCode from '~src/util/apiErrorWithStatusCode'; +import messages from '~src/util/messages'; + +import getSubstrateAddress from '~src/util/getSubstrateAddress'; +interface IGetProfileWithAddress { + address?: string | string[]; +} + +export interface IGetProfileWithAddressResponse { + profile: ProfileDetails; + username: string; +} + +export async function getProfileWithAddress(params: IGetProfileWithAddress): Promise> { + try { + const { address } = params; + if (!address) { + throw apiErrorWithStatusCode('Invalid address.', 400); + } + const substrateAddress = getSubstrateAddress(String(address)); + if (!substrateAddress) { + throw apiErrorWithStatusCode('Invalid substrate address', 500); + } + const addressDoc = await firestore_db.collection('addresses').doc(substrateAddress).get(); + if(!addressDoc.exists) { + throw apiErrorWithStatusCode(`No user found with the address '${address}'.`, 404); + } + + const userDoc = await firestore_db.collection('users').doc(String(addressDoc.data()?.user_id)).get(); + if(!userDoc.exists) { + throw apiErrorWithStatusCode(`No user found with the address '${address}'.`, 404); + } + const userData = userDoc.data() as User; + + const profile = userData.profile as ProfileDetails; + const data: IGetProfileWithAddressResponse = { + profile, + username: userData.username || '' + }; + return { + data: JSON.parse(JSON.stringify(data)), + error: null, + status: 200 + }; + } catch (error) { + return { + data: null, + error: error.message || messages.API_FETCH_ERROR, + status: Number(error.name) || 500 + }; + } +} + +async function handler(req: NextApiRequest, res: NextApiResponse) { + const { address } = req.query; + + const { data, error, status } = await getProfileWithAddress({ + address + }); + + if(error || !data) { + res.status(status).json({ message: error || messages.API_FETCH_ERROR }); + }else { + res.status(status).json(data); + } +} + +export default withErrorHandling(handler); \ No newline at end of file diff --git a/pages/api/v1/auth/data/subscription.ts b/pages/api/v1/auth/data/subscription.ts new file mode 100644 index 0000000000..5985e3777c --- /dev/null +++ b/pages/api/v1/auth/data/subscription.ts @@ -0,0 +1,52 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import { NextApiRequest, NextApiResponse } from 'next'; + +import withErrorHandling from '~src/api-middlewares/withErrorHandling'; +import { isFirestoreProposalTypeValid, isValidNetwork } from '~src/api-utils'; +import { networkDocRef } from '~src/api-utils/firestore_refs'; +import authServiceInstance from '~src/auth/auth'; +import { IUserPreference, MessageType, Subscription } from '~src/auth/types'; +import getTokenFromReq from '~src/auth/utils/getTokenFromReq'; +import messages from '~src/auth/utils/messages'; + +async function handler(req: NextApiRequest, res: NextApiResponse) { + + const network = String(req.headers['x-network']); + if(!network || !isValidNetwork(network)) res.status(400).json({ message: 'Invalid network in request header' }); + + const { post_id = 0, proposalType } = req.body; + + const strProposalType = String(proposalType); + if (!isFirestoreProposalTypeValid(strProposalType)) { + return res.status(400).json({ message: `The proposal type "${proposalType}" is invalid.` }); + } + const numPostId = Number(post_id); + if (isNaN(numPostId)) { + return res.status(400).json({ message: `The postId "${post_id}" is invalid.` }); + } + + const token = getTokenFromReq(req); + + if (!token) res.status(400).json({ message: 'Token not found' }); + + const user = await authServiceInstance.GetUser(token); + if(!user) return res.status(400).json({ message: messages.USER_NOT_FOUND }); + + let subscribed = false; + const userPreferenceDoc = await networkDocRef(network).collection('user_preferences').doc(strProposalType).get(); + if (userPreferenceDoc.exists) { + const data = userPreferenceDoc.data() as IUserPreference; + if (data) { + const post_subscriptions = data.post_subscriptions; + subscribed = post_subscriptions?.[strProposalType as keyof typeof post_subscriptions]?.some((id) => String(id) === String(post_id)) || false; + } else { + subscribed = false; + } + } + res.status(200).json({ subscribed }); +} + +export default withErrorHandling(handler); diff --git a/pages/api/v1/auth/data/user.ts b/pages/api/v1/auth/data/user.ts new file mode 100644 index 0000000000..f8d39c1631 --- /dev/null +++ b/pages/api/v1/auth/data/user.ts @@ -0,0 +1,36 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. +import { NextApiRequest, NextApiResponse } from 'next'; + +import withErrorHandling from '~src/api-middlewares/withErrorHandling'; +import { MessageType, PublicUser, User } from '~src/auth/types'; +import getAddressesFromUserId from '~src/auth/utils/getAddressesFromUserId'; +import firebaseAdmin from '~src/services/firebaseInit'; + +async function handler(req: NextApiRequest, res: NextApiResponse) { + const firestore = firebaseAdmin.firestore(); + const { userId = null } = req.query; + + if (!userId || isNaN(Number(userId))) return res.status(400).json({ message: 'Invalid id.' }); + + const userQuerySnapshot = await firestore.collection('users').where('id', '==', Number(userId)).limit(1).get(); + + if(userQuerySnapshot.size == 0) return res.status(404).json({ message: `No user found with the id '${userId}'.` }); + + const userDoc = userQuerySnapshot.docs[0].data() as User; + + const addresses = await getAddressesFromUserId(Number(userId)); + + const default_address = addresses.find((address: any) => address.default === true); + + const user: PublicUser = { + default_address: default_address?.address || '', + id: userDoc.id, + username: userDoc.username + }; + + res.status(200).json(user); +} + +export default withErrorHandling(handler); \ No newline at end of file diff --git a/pages/api/v1/auth/data/userDetails.ts b/pages/api/v1/auth/data/userDetails.ts new file mode 100644 index 0000000000..6e2de22c8a --- /dev/null +++ b/pages/api/v1/auth/data/userDetails.ts @@ -0,0 +1,39 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. +import { NextApiRequest, NextApiResponse } from 'next'; + +import withErrorHandling from '~src/api-middlewares/withErrorHandling'; +import { MessageType, ProfileDetailsResponse, User } from '~src/auth/types'; +import getAddressesFromUserId from '~src/auth/utils/getAddressesFromUserId'; +import firebaseAdmin from '~src/services/firebaseInit'; + +async function handler(req: NextApiRequest, res: NextApiResponse) { + const firestore = firebaseAdmin.firestore(); + const { userId = null } = req.query; + + if (!userId || isNaN(Number(userId))) return res.status(400).json({ message: 'Invalid id.' }); + + const userQuerySnapshot = await firestore.collection('users').where('id', '==', Number(userId)).limit(1).get(); + + if(userQuerySnapshot.size == 0) return res.status(404).json({ message: `No user found with the id '${userId}'.` }); + + const userDoc = userQuerySnapshot.docs[0].data() as User; + const user_addresses = await getAddressesFromUserId(userDoc.id); + + const user: ProfileDetailsResponse = { + addresses: user_addresses.map((a) => a?.address) || [], + badges: [], + bio: '', + image: '', + social_links: [], + title: '', + user_id: userDoc.id, + username: userDoc.username, + ...userDoc.profile + }; + + res.status(200).json(user); +} + +export default withErrorHandling(handler); \ No newline at end of file diff --git a/pages/api/v1/auth/data/userProfileWithUsername.ts b/pages/api/v1/auth/data/userProfileWithUsername.ts new file mode 100644 index 0000000000..443a2aa068 --- /dev/null +++ b/pages/api/v1/auth/data/userProfileWithUsername.ts @@ -0,0 +1,62 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. +import { NextApiRequest, NextApiResponse } from 'next'; + +import withErrorHandling from '~src/api-middlewares/withErrorHandling'; +import { MessageType, ProfileDetailsResponse, User } from '~src/auth/types'; +import getAddressesFromUserId from '~src/auth/utils/getAddressesFromUserId'; +import messages from '~src/auth/utils/messages'; +import { firestore_db } from '~src/services/firebaseInit'; +import { IApiResponse } from '~src/types'; +import apiErrorWithStatusCode from '~src/util/apiErrorWithStatusCode'; + +export async function getUserProfileWithUsername(username: string) : Promise> { + try{ + const userQuerySnapshot = await firestore_db.collection('users').where('username', '==', username).limit(1).get(); + + if(userQuerySnapshot.size == 0) throw apiErrorWithStatusCode(messages.NO_USER_FOUND_WITH_USERNAME, 404); + + const userDoc = userQuerySnapshot.docs[0].data() as User; + const user_addresses = await getAddressesFromUserId(userDoc.id); + + const user: ProfileDetailsResponse = { + addresses: user_addresses.map((a) => a?.address) || [], + badges: [], + bio: '', + image: '', + title: '', + user_id: userDoc.id, + username: userDoc.username, + ...userDoc.profile + }; + + return { + data: JSON.parse(JSON.stringify(user)), + error: null, + status: 200 + }; + } catch (error) { + return { + data: null, + error: error.message, + status: Number(error.name) || 500 + }; + } +} + +async function handler(req: NextApiRequest, res: NextApiResponse) { + const { username = '' } = req.query; + if (typeof username !== 'string' || !username) return res.status(400).json({ message: 'Invalid username.' }); + + const { data, error, status } = await getUserProfileWithUsername(username); + + if(error || !data) { + res.status(status).json({ message: error || messages.API_FETCH_ERROR }); + }else { + res.status(status).json(data); + } + +} + +export default withErrorHandling(handler); \ No newline at end of file diff --git a/pages/api/v1/events/getEventByPostId.ts b/pages/api/v1/events/getEventByPostId.ts new file mode 100644 index 0000000000..1e367651c1 --- /dev/null +++ b/pages/api/v1/events/getEventByPostId.ts @@ -0,0 +1,28 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import { NextApiHandler } from 'next'; + +import withErrorHandling from '~src/api-middlewares/withErrorHandling'; +import { MessageType } from '~src/auth/types'; +import firebaseAdmin from '~src/services/firebaseInit'; +import { NetworkEvent } from '~src/types'; + +const handler: NextApiHandler = async (req, res) => { + const network = String(req.headers['x-network']); + if(!network) return res.status(400).json({ message: 'Missing network name in request headers' }); + + const { post_id } = req.body; + if(!post_id) res.status(400).json({ message: 'post_id is required' }); + + const firestore = firebaseAdmin.firestore(); + + const eventQuerySnapshot = await firestore.collection('networks').doc(network).collection('events').where('post_id', '==', Number(post_id)).get(); + if(eventQuerySnapshot.empty) return res.status(404).json({ message: 'No events found for this post' }); + + const event = eventQuerySnapshot.docs[0].data() as NetworkEvent; + + res.status(200).json(event); +}; +export default withErrorHandling(handler); \ No newline at end of file diff --git a/pages/api/v1/events/index.ts b/pages/api/v1/events/index.ts new file mode 100644 index 0000000000..13f5f680b4 --- /dev/null +++ b/pages/api/v1/events/index.ts @@ -0,0 +1,29 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import { NextApiHandler } from 'next'; + +import withErrorHandling from '~src/api-middlewares/withErrorHandling'; +import { isValidNetwork } from '~src/api-utils'; +import { networkDocRef } from '~src/api-utils/firestore_refs'; +import { MessageType } from '~src/auth/types'; +import { approvalStatus } from '~src/global/statuses'; +import { NetworkEvent } from '~src/types'; + +const handler: NextApiHandler = async (req, res) => { + const { approval_status = approvalStatus.APPROVED } = req.body; + + const network = String(req.headers['x-network']); + if(!network || !isValidNetwork(network)) res.status(400).json({ message: 'Invalid network in request header' }); + + const eventsColSnapshot = await networkDocRef(network).collection('events').where('status', '==', approval_status ).get(); + const events: NetworkEvent[] = eventsColSnapshot.docs.reduce((events, doc) => { + if (doc && doc.exists) { + return [...events, (doc.data() as NetworkEvent)]; + } + return events; + }, [] as NetworkEvent[]); + res.status(200).json(events); +}; +export default withErrorHandling(handler); \ No newline at end of file diff --git a/pages/api/v1/getAddressesData/index.ts b/pages/api/v1/getAddressesData/index.ts new file mode 100644 index 0000000000..62d3432ba3 --- /dev/null +++ b/pages/api/v1/getAddressesData/index.ts @@ -0,0 +1,51 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. +import { firestore } from 'firebase-admin'; +import { NextApiHandler } from 'next'; + +import withErrorHandling from '~src/api-middlewares/withErrorHandling'; +import { firestore_db } from '~src/services/firebaseInit'; + +export interface IAddressData { + address: string; + user_id: number; +} + +export interface IAddressesResponse { + addressesData: IAddressData[]; +} + +const handler: NextApiHandler = async (req, res) => { + const { addresses } = req.body; + if (!addresses || !Array.isArray(addresses)) return res.status(400).json({ error: `addresses ${addresses} must be an array of string.` }); + + const docRefList: firestore.DocumentReference[] = []; + addresses.forEach((address) => { + docRefList.push(firestore_db.collection('addresses').doc(address)); + }); + + const addressesData: IAddressData[] = []; + if (docRefList.length > 0) { + // getAll must have one docRef + const results = await firestore_db.getAll(...docRefList); + + results.forEach((doc) => { + if (doc.exists) { + const data = doc.data(); + if (data) { + addressesData.push({ + address: data.address, + user_id: data.user_id + }); + } + } + }); + } + + res.status(200).json({ + addressesData + }); +}; + +export default withErrorHandling(handler); \ No newline at end of file diff --git a/pages/api/v1/latest-activity/all-posts.ts b/pages/api/v1/latest-activity/all-posts.ts new file mode 100644 index 0000000000..a68327eb56 --- /dev/null +++ b/pages/api/v1/latest-activity/all-posts.ts @@ -0,0 +1,161 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import { NextApiHandler } from 'next'; + +import withErrorHandling from '~src/api-middlewares/withErrorHandling'; +import { isGovTypeValid, isValidNetwork } from '~src/api-utils'; +import { postsByTypeRef } from '~src/api-utils/firestore_refs'; +import { LISTING_LIMIT } from '~src/global/listingLimit'; +import { getFirestoreProposalType, gov1ProposalTypes, ProposalType } from '~src/global/proposalType'; +import { GET_PROPOSALS_LISTING_BY_TYPE } from '~src/queries'; +import { IApiResponse } from '~src/types'; +import apiErrorWithStatusCode from '~src/util/apiErrorWithStatusCode'; +import fetchSubsquid from '~src/util/fetchSubsquid'; +import { getTopicFromType, getTopicNameFromTopicId, isTopicIdValid } from '~src/util/getTopicFromType'; +import messages from '~src/util/messages'; + +import { getProposerAddressFromFirestorePostData } from '../listing/on-chain-posts'; +import { ILatestActivityPostsListingResponse } from './on-chain-posts'; + +interface IGetLatestActivityAllPostsParams { + listingLimit?: string | string[] | number; + network: string; + govType?: string | string[]; +} + +export async function getLatestActivityAllPosts(params: IGetLatestActivityAllPostsParams): Promise> { + try { + const { listingLimit, network, govType } = params; + + const numListingLimit = Number(listingLimit); + if (isNaN(numListingLimit)) { + throw apiErrorWithStatusCode(`Invalid listingLimit "${listingLimit}"`, 400); + } + + const strGovType = String(govType); + if (govType !== undefined && govType !== null && !isGovTypeValid(strGovType)) { + throw apiErrorWithStatusCode(`Invalid govType "${govType}"`, 400); + } + + const variables: any = { + limit: numListingLimit, + type_in: gov1ProposalTypes + }; + + if (strGovType === 'open_gov') { + variables.type_in = 'ReferendumV2'; + } + + const subsquidRes = await fetchSubsquid({ + network, + query: GET_PROPOSALS_LISTING_BY_TYPE, + variables + }); + + const subsquidData = subsquidRes?.data; + const subsquidPosts: any[] = subsquidData?.proposals || []; + + const onChainPostsPromise = subsquidPosts?.map(async (subsquidPost) => { + const { createdAt, proposer, preimage, type, index, status, hash, method, origin, trackNumber, curator, description, proposalArguments } = subsquidPost; + const postId = type === 'Tip'? hash: index; + const postDocRef = postsByTypeRef(network, getFirestoreProposalType(type) as ProposalType).doc(String(postId)); + const postDoc = await postDocRef.get(); + const onChainPost = { + created_at: createdAt, + description: description || (proposalArguments? proposalArguments?.description: null), + hash, + method: method || preimage?.method || (proposalArguments? proposalArguments?.method: proposalArguments?.method), + origin, + post_id: postId, + proposer: proposer || preimage?.proposer || curator, + status: status, + title: '', + track_number: trackNumber, + type + }; + if (postDoc && postDoc.exists) { + const data = postDoc?.data(); + return { + ...onChainPost, + title: data?.title || null + }; + } + return onChainPost; + }); + + const onChainPosts = await Promise.all(onChainPostsPromise); + const onChainPostsCount = Number(subsquidData?.proposalsConnection?.totalCount || 0); + + const discussionsPostsColRef = postsByTypeRef(network, ProposalType.DISCUSSIONS); + const postsSnapshotArr = await discussionsPostsColRef + .orderBy('created_at', 'desc') + .limit(numListingLimit) + .get(); + + const offChainPosts: any[] = []; + const offChainPostsCount = (await discussionsPostsColRef.count().get()).data().count; + + postsSnapshotArr.docs.forEach((doc) => { + if (doc && doc.exists) { + const data = doc.data(); + if (data) { + const { topic, topic_id } = data; + offChainPosts.push({ + created_at: data?.created_at?.toDate? data?.created_at?.toDate(): data?.created_at, + post_id: data?.id, + proposer: getProposerAddressFromFirestorePostData(data, network), + title: data?.title, + topic: topic? topic: isTopicIdValid(topic_id)? { + id: topic_id, + name: getTopicNameFromTopicId(topic_id) + }: getTopicFromType(ProposalType.DISCUSSIONS), + type: 'Discussions', + username: data?.username || '' + }); + } + } + }); + + const allPosts = [...onChainPosts, ...offChainPosts]; + const deDupedAllPosts = Array.from(new Set(allPosts)); + deDupedAllPosts.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()); + + const data: ILatestActivityPostsListingResponse = { + count: onChainPostsCount + offChainPostsCount, + posts: deDupedAllPosts.slice(0, numListingLimit) + }; + return { + data: JSON.parse(JSON.stringify(data)), + error: null, + status: 200 + }; + } catch (error) { + return { + data: null, + error: error.message || messages.API_FETCH_ERROR, + status: Number(error.name) || 500 + }; + } +} + +const handler: NextApiHandler = async (req, res) => { + const { govType, listingLimit = LISTING_LIMIT } = req.query; + + const network = String(req.headers['x-network']); + if(!network || !isValidNetwork(network)) res.status(400).json({ error: 'Invalid network in request header' }); + + const { data, error, status } = await getLatestActivityAllPosts({ + govType, + listingLimit, + network + }); + + if(error || !data) { + res.status(status).json({ error: error || messages.API_FETCH_ERROR }); + }else { + res.status(status).json(data); + } +}; +export default withErrorHandling(handler); \ No newline at end of file diff --git a/pages/api/v1/latest-activity/off-chain-posts.ts b/pages/api/v1/latest-activity/off-chain-posts.ts new file mode 100644 index 0000000000..abb9d5b00a --- /dev/null +++ b/pages/api/v1/latest-activity/off-chain-posts.ts @@ -0,0 +1,105 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import { NextApiHandler } from 'next'; + +import withErrorHandling from '~src/api-middlewares/withErrorHandling'; +import { isFirestoreProposalTypeValid, isValidNetwork } from '~src/api-utils'; +import { postsByTypeRef } from '~src/api-utils/firestore_refs'; +import { LISTING_LIMIT } from '~src/global/listingLimit'; +import { OffChainProposalType, ProposalType } from '~src/global/proposalType'; +import { IApiResponse } from '~src/types'; +import apiErrorWithStatusCode from '~src/util/apiErrorWithStatusCode'; +import { getTopicFromType, getTopicNameFromTopicId, isTopicIdValid } from '~src/util/getTopicFromType'; +import messages from '~src/util/messages'; + +import { getProposerAddressFromFirestorePostData } from '../listing/on-chain-posts'; +import { ILatestActivityPostsListingResponse } from './on-chain-posts'; + +interface IGetLatestActivityOffChainPostsParams { + listingLimit?: string | string[] | number; + proposalType: OffChainProposalType | string | string[]; + network: string; +} + +export async function getLatestActivityOffChainPosts(params: IGetLatestActivityOffChainPostsParams): Promise> { + try { + const { listingLimit, network, proposalType } = params; + + const numListingLimit = Number(listingLimit); + if (isNaN(numListingLimit)) { + throw apiErrorWithStatusCode(`Invalid listingLimit "${listingLimit}"`, 400); + } + + const strProposalType = String(proposalType); + if (!isFirestoreProposalTypeValid(strProposalType)) { + throw apiErrorWithStatusCode(`The off chain proposal type of the name "${proposalType}" does not exist.`, 400); + } + + const postsColRef = postsByTypeRef(network, strProposalType as ProposalType); + const postsSnapshotArr = await postsColRef + .orderBy('created_at', 'desc') + .limit(numListingLimit) + .get(); + const count = (await postsColRef.count().get()).data().count; + + const posts: any[] = []; + postsSnapshotArr.docs.forEach((doc) => { + if (doc && doc.exists) { + const data = doc.data(); + if (data) { + const { topic, topic_id } = data; + posts.push({ + created_at: data?.created_at?.toDate? data?.created_at?.toDate(): data?.created_at, + post_id: data?.id, + proposer: getProposerAddressFromFirestorePostData(data, network), + title: data?.title, + topic: topic? topic: isTopicIdValid(topic_id)? { + id: topic_id, + name: getTopicNameFromTopicId(topic_id) + }: getTopicFromType(ProposalType.DISCUSSIONS), + type: proposalType, + username: data?.username + }); + } + } + }); + + const data: ILatestActivityPostsListingResponse = { + count, + posts + }; + return { + data: JSON.parse(JSON.stringify(data)), + error: null, + status: 200 + }; + } catch (error) { + return { + data: null, + error: error.message || messages.API_FETCH_ERROR, + status: Number(error.name) || 500 + }; + } +} + +const handler: NextApiHandler = async (req, res) => { + const { proposalType = OffChainProposalType.DISCUSSIONS, listingLimit = LISTING_LIMIT } = req.query; + + const network = String(req.headers['x-network']); + if(!network || !isValidNetwork(network)) res.status(400).json({ error: 'Invalid network in request header' }); + + const { data, error, status } = await getLatestActivityOffChainPosts({ + listingLimit, + network, + proposalType + }); + + if(error || !data) { + res.status(status).json({ error: error || messages.API_FETCH_ERROR }); + }else { + res.status(status).json(data); + } +}; +export default withErrorHandling(handler); \ No newline at end of file diff --git a/pages/api/v1/latest-activity/on-chain-posts.ts b/pages/api/v1/latest-activity/on-chain-posts.ts new file mode 100644 index 0000000000..6c0a431745 --- /dev/null +++ b/pages/api/v1/latest-activity/on-chain-posts.ts @@ -0,0 +1,163 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import { NextApiHandler } from 'next'; + +import withErrorHandling from '~src/api-middlewares/withErrorHandling'; +import { isProposalTypeValid, isTrackNoValid, isValidNetwork } from '~src/api-utils'; +import { postsByTypeRef } from '~src/api-utils/firestore_refs'; +import { LISTING_LIMIT } from '~src/global/listingLimit'; +import { getSubsquidProposalType, ProposalType } from '~src/global/proposalType'; +import { GET_PROPOSALS_LISTING_BY_TYPE } from '~src/queries'; +import { IApiResponse } from '~src/types'; +import apiErrorWithStatusCode from '~src/util/apiErrorWithStatusCode'; +import fetchSubsquid from '~src/util/fetchSubsquid'; +import messages from '~src/util/messages'; + +import { getProposerAddressFromFirestorePostData } from '../listing/on-chain-posts'; + +export interface ILatestActivityPostsListingResponse { + count: number; + posts: any; +} + +interface IGetLatestActivityOnChainPostsParams { + network: string; + listingLimit: string | string[] | number; + proposalType: ProposalType | string | string[]; + trackNo?: number | string | string[]; +} + +export async function getLatestActivityOnChainPosts(params: IGetLatestActivityOnChainPostsParams): Promise> { + try { + const { network, proposalType, trackNo, listingLimit } = params; + + const numListingLimit = Number(listingLimit); + if (isNaN(numListingLimit)) { + throw apiErrorWithStatusCode( `Invalid listingLimit "${listingLimit}"`, 400); + } + + let strProposalType = String(proposalType); + if (!isProposalTypeValid(strProposalType)) { + throw apiErrorWithStatusCode(`The proposal type of the name "${proposalType}" does not exist.`, 400); + } + + const numTrackNo = Number(trackNo); + if (strProposalType === ProposalType.OPEN_GOV) { + if (!isTrackNoValid(numTrackNo, network)) { + throw apiErrorWithStatusCode(`The OpenGov trackNo "${trackNo}" is invalid.`, 400); + } + } + const subsquidProposalType = getSubsquidProposalType(proposalType as any); + + const postsVariables: any = { + limit: numListingLimit, + type_in: subsquidProposalType + }; + + if (proposalType === ProposalType.OPEN_GOV) { + strProposalType = 'referendums_v2'; + postsVariables.trackNumber_in = numTrackNo; + } + + const subsquidRes = await fetchSubsquid({ + network, + query: GET_PROPOSALS_LISTING_BY_TYPE, + variables: postsVariables + }); + + const subsquidData = subsquidRes?.data; + const subsquidPosts: any[] = subsquidData?.proposals || []; + + const postsPromise = subsquidPosts?.map(async (subsquidPost) => { + const { createdAt, proposer, curator, preimage, type, index, status, hash, method, origin, trackNumber, group, description } = subsquidPost; + let otherPostProposer = ''; + if (group?.proposals?.length) { + group.proposals.forEach((obj: any) => { + if (!otherPostProposer) { + if (obj.proposer) { + otherPostProposer = obj.proposer; + } else if (obj?.preimage?.proposer) { + otherPostProposer = obj.preimage.proposer; + } + } + }); + } + const postId = proposalType === ProposalType.TIPS? hash: index; + const postDocRef = postsByTypeRef(network, strProposalType as ProposalType).doc(String(postId)); + const postDoc = await postDocRef.get(); + if (postDoc && postDoc.exists) { + const data = postDoc?.data(); + if (data) { + const proposer_address = getProposerAddressFromFirestorePostData(data, network); + return { + created_at: createdAt, + description, + hash, + method: method || preimage?.method, + origin, + post_id: postId, + proposer: proposer || preimage?.proposer || otherPostProposer || proposer_address || curator, + status: status, + title: data?.title || null, + track_number: trackNumber, + type + }; + } + } + return { + created_at: createdAt, + description, + hash, + method: method || preimage?.method, + origin, + post_id: postId, + proposer: proposer || preimage?.proposer || otherPostProposer || curator, + status: status, + title: '', + track_number: trackNumber, + type + }; + }); + + const posts = await Promise.all(postsPromise); + + const data: ILatestActivityPostsListingResponse = { + count: Number(subsquidData?.proposalsConnection.totalCount), + posts + }; + return { + data: JSON.parse(JSON.stringify(data)), + error: null, + status: 200 + }; + } catch (error) { + return { + data: null, + error: error.message || messages.API_FETCH_ERROR, + status: Number(error.name) || 500 + }; + } +} + +const handler: NextApiHandler = async (req, res) => { + const { trackNo, proposalType = ProposalType.DEMOCRACY_PROPOSALS, listingLimit = LISTING_LIMIT } = req.query; + + const network = String(req.headers['x-network']); + if(!network || !isValidNetwork(network)) res.status(400).json({ error: 'Invalid network in request header' }); + + const { data, error, status } = await getLatestActivityOnChainPosts({ + listingLimit, + network, + proposalType, + trackNo + }); + + if(error || !data) { + res.status(status).json({ error: error || messages.API_FETCH_ERROR }); + }else { + res.status(status).json(data); + } +}; +export default withErrorHandling(handler); \ No newline at end of file diff --git a/pages/api/v1/listing/off-chain-posts.ts b/pages/api/v1/listing/off-chain-posts.ts new file mode 100644 index 0000000000..cdba235d60 --- /dev/null +++ b/pages/api/v1/listing/off-chain-posts.ts @@ -0,0 +1,166 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import type { NextApiHandler } from 'next'; + +import withErrorHandling from '~src/api-middlewares/withErrorHandling'; +import { isFirestoreProposalTypeValid, isSortByValid, isValidNetwork } from '~src/api-utils'; +import { postsByTypeRef } from '~src/api-utils/firestore_refs'; +import { LISTING_LIMIT } from '~src/global/listingLimit'; +import { OffChainProposalType, ProposalType } from '~src/global/proposalType'; +import { sortValues } from '~src/global/sortOptions'; +import { firestore_db } from '~src/services/firebaseInit'; +import { IApiErrorResponse, IApiResponse } from '~src/types'; +import apiErrorWithStatusCode from '~src/util/apiErrorWithStatusCode'; +import { getTopicFromType, isTopicIdValid } from '~src/util/getTopicFromType'; +import { getTopicNameFromTopicId } from '~src/util/getTopicFromType'; +import messages from '~src/util/messages'; + +import { getReactions } from '../posts/on-chain-post'; +import { getProposerAddressFromFirestorePostData, IPostsListingResponse } from './on-chain-posts'; + +interface IGetOffChainPostsParams { + network: string; + page?: string | string[] | number; + sortBy: string | string[]; + listingLimit: string | string[] | number; + proposalType: OffChainProposalType | string | string[]; +} + +export async function getOffChainPosts(params: IGetOffChainPostsParams) : Promise> { + try { + const { network, listingLimit, page, proposalType, sortBy } = params; + const strSortBy = String(sortBy); + + const numListingLimit = Number(listingLimit); + if (isNaN(numListingLimit)) { + throw apiErrorWithStatusCode( `Invalid listingLimit "${listingLimit}"`, 400); + } + + const strProposalType = String(proposalType); + if (!isFirestoreProposalTypeValid(strProposalType)) { + throw apiErrorWithStatusCode(`The off chain proposal type of the name "${proposalType}" does not exist.`, 400); + } + + const numPage = Number(page); + if (isNaN(numPage) || numPage <= 0) { + throw apiErrorWithStatusCode(`Invalid page "${page}"`, 400); + } + + if (!isSortByValid(strSortBy)) { + throw apiErrorWithStatusCode('sortBy is invalid', 400); + } + let order: 'desc' | 'asc' = sortBy === sortValues.NEWEST ? 'desc' : 'asc'; + let orderedField = 'created_at'; + if (sortBy === sortValues.COMMENTED) { + order = 'desc'; + orderedField = 'last_comment_at'; + } + + const offChainCollRef = postsByTypeRef(network, strProposalType as ProposalType); + const postsSnapshotArr = await offChainCollRef + .orderBy(orderedField, order) + .limit(Number(listingLimit) || LISTING_LIMIT) + .offset((Number(page) - 1) * Number(listingLimit || LISTING_LIMIT)) + .get(); + + const count = (await offChainCollRef.count().get()).data().count; + + const postsPromise = postsSnapshotArr.docs.map(async (doc) => { + if (doc && doc.exists) { + const docData = doc.data(); + if (docData) { + const postDocRef = offChainCollRef.doc(String(docData.id)); + + const post_reactionsQuerySnapshot = await postDocRef.collection('post_reactions').get(); + const reactions = getReactions(post_reactionsQuerySnapshot); + const post_reactions = { + '👍': reactions['👍']?.count || 0, + '👎': reactions['👎']?.count || 0 + }; + + const commentsQuerySnapshot = await postDocRef.collection('comments').count().get(); + + const created_at = docData.created_at; + const { topic, topic_id } = docData; + return { + comments_count: commentsQuerySnapshot.data()?.count || 0, + created_at: created_at?.toDate? created_at?.toDate(): created_at, + post_id: docData.id, + post_reactions, + proposer: getProposerAddressFromFirestorePostData(docData, network), + title: docData?.title || null, + topic: topic? topic: isTopicIdValid(topic_id)? { + id: topic_id, + name: getTopicNameFromTopicId(topic_id) + }: getTopicFromType(strProposalType as ProposalType), + user_id: docData?.user_id || 1, + username: docData?.username + }; + } + } + }); + + const posts = await Promise.all(postsPromise); + const indexMap: any = {}; + const ids = posts.map((post, index) => { + indexMap[post?.user_id] = index; + return post?.user_id; + }); + + const newIds = ids.filter((id) => id && !isNaN(id)); + + if (newIds.length > 0) { + const addressesQuery = await firestore_db.collection('addresses').where('user_id', 'in', newIds).get(); + addressesQuery.docs.map((doc) => { + if (doc && doc.exists) { + const data = doc.data(); + if (posts[indexMap[data.user_id]] && !posts[indexMap[data.user_id]]?.proposer) { + (posts[indexMap[data.user_id]] as any).proposer = data.address; + } + } + }); + } + + const data: IPostsListingResponse = { + count, + posts: posts.filter((post) => post !== undefined) as any + }; + + return { + data: JSON.parse(JSON.stringify(data)), + error: null, + status: 200 + }; + } catch (error) { + return { + data: null, + error: error.message || messages.API_FETCH_ERROR, + status: Number(error.name) || 500 + }; + } +} + +// expects page, sortBy, proposalType and listingLimit +const handler: NextApiHandler = async (req, res) => { + const { page = 1, proposalType = OffChainProposalType.DISCUSSIONS, sortBy = sortValues.COMMENTED, listingLimit = LISTING_LIMIT } = req.query; + const network = String(req.headers['x-network']); + if(!network || !isValidNetwork(network)) res.status(400).json({ error: 'Invalid network in request header' }); + + const { data, error, status } = await getOffChainPosts({ + listingLimit, + network, + page, + proposalType, + sortBy + }); + + if(error || !data) { + res.status(status).json({ error: error || messages.API_FETCH_ERROR }); + }else { + res.status(status).json(data); + } +}; + +export default withErrorHandling(handler); \ No newline at end of file diff --git a/pages/api/v1/listing/on-chain-posts.ts b/pages/api/v1/listing/on-chain-posts.ts new file mode 100644 index 0000000000..64ae386933 --- /dev/null +++ b/pages/api/v1/listing/on-chain-posts.ts @@ -0,0 +1,291 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import type { NextApiHandler } from 'next'; + +import withErrorHandling from '~src/api-middlewares/withErrorHandling'; +import { isCustomOpenGovStatusValid, isProposalTypeValid, isSortByValid, isTrackNoValid, isTrackPostStatusValid, isValidNetwork } from '~src/api-utils'; +import { postsByTypeRef } from '~src/api-utils/firestore_refs'; +import { LISTING_LIMIT } from '~src/global/listingLimit'; +import { getStatusesFromCustomStatus, getSubsquidProposalType, ProposalType } from '~src/global/proposalType'; +import { sortValues } from '~src/global/sortOptions'; +import { GET_PROPOSALS_LISTING_BY_TYPE } from '~src/queries'; +import { IApiResponse } from '~src/types'; +import apiErrorWithStatusCode from '~src/util/apiErrorWithStatusCode'; +import fetchSubsquid from '~src/util/fetchSubsquid'; +import getEncodedAddress from '~src/util/getEncodedAddress'; +import { getTopicFromType, getTopicNameFromTopicId, isTopicIdValid } from '~src/util/getTopicFromType'; +import messages from '~src/util/messages'; + +import { getReactions } from '../posts/on-chain-post'; + +export interface IPostListing { + user_id?: string | number; + comments_count: number; + created_at: string; + end?: number; + hash?: string; + post_id: string | number; + description?: string; + post_reactions: { + '👍': number; + '👎': number; + }; + proposer?: string; + curator?: string; + method?: string; + status?: string; + title: string; + topic: { + id: number; + name: string; + }; + type?: string; + username?: string +} + +export interface IPostsListingResponse { + count: number + posts: IPostListing[] +} + +export function getGeneralStatus(status: string) { + switch(status) { + case 'DecisionDepositPlaced': + return 'Deciding'; + } + return status; +} + +interface IGetOnChainPostsParams { + network: string; + page?: string | string[] | number; + sortBy: string | string[]; + trackNo?: string | string[] | number; + listingLimit: string | string[] | number; + trackStatus?: string | string[]; + proposalType?: string | string[]; + postIds?: string | string[] | number[]; +} + +export function getProposerAddressFromFirestorePostData(data: any, network: string) { + let proposer_address = ''; + if (data) { + if (Array.isArray(data?.proposer_address)) { + if (data.proposer_address.length > 0) { + proposer_address = data?.proposer_address[0]; + } + } else if (typeof data.proposer_address === 'string') { + proposer_address = data.proposer_address; + } + if (data?.default_address && !proposer_address) { + proposer_address = data?.default_address; + } + } + + if(proposer_address.startsWith('0x')) { + return proposer_address; + } + + return (proposer_address && getEncodedAddress(proposer_address, network)) || proposer_address; +} + +export async function getOnChainPosts(params: IGetOnChainPostsParams) : Promise> { + try { + const { listingLimit, network, page, proposalType, sortBy, trackNo, trackStatus, postIds } = params; + + const numListingLimit = Number(listingLimit); + if (isNaN(numListingLimit)) { + throw apiErrorWithStatusCode( `Invalid listingLimit "${listingLimit}"`, 400); + } + + const numPage = Number(page); + if (isNaN(numPage) || numPage <= 0) { + throw apiErrorWithStatusCode(`Invalid page "${page}"`, 400); + } + + const strSortBy = String(sortBy); + if (!isSortByValid(strSortBy)) { + throw apiErrorWithStatusCode('sortBy is invalid', 400); + } + + let strProposalType = String(proposalType); + if (!isProposalTypeValid(strProposalType)) { + throw apiErrorWithStatusCode(`The proposal type of the name "${proposalType}" does not exist.`, 400); + } + + const numTrackNo = Number(trackNo); + const strTrackStatus = String(trackStatus); + if (strProposalType === ProposalType.OPEN_GOV) { + if (!isTrackNoValid(numTrackNo, network)) { + throw apiErrorWithStatusCode(`The OpenGov trackNo "${trackNo}" is invalid.`, 400); + } + if (trackStatus !== undefined && trackStatus !== null && !isTrackPostStatusValid(strTrackStatus) && !isCustomOpenGovStatusValid(strTrackStatus)) { + throw apiErrorWithStatusCode(`The Track status of the name "${trackStatus}" is invalid.`, 400); + } + } + + const topicFromType = getTopicFromType(proposalType as ProposalType); + + const subsquidProposalType = getSubsquidProposalType(proposalType as any); + + const orderBy = strSortBy === 'newest'? 'createdAtBlock_DESC': 'createdAtBlock_ASC'; + const postsVariables: any = { + limit: numListingLimit, + offset: numListingLimit * (numPage - 1), + orderBy: orderBy, + type_in: subsquidProposalType + }; + + if (proposalType === ProposalType.OPEN_GOV) { + strProposalType = 'referendums_v2'; + postsVariables.trackNumber_in = numTrackNo; + if (strTrackStatus && strTrackStatus !== 'All' && isCustomOpenGovStatusValid(strTrackStatus)) { + postsVariables.status_in = getStatusesFromCustomStatus(strTrackStatus as any); + } + } else if (strProposalType === ProposalType.FELLOWSHIP_REFERENDUMS) { + if (numTrackNo !== undefined && numTrackNo !== null && !isNaN(numTrackNo)) { + postsVariables.trackNumber_in = numTrackNo; + } + } + + if (postIds && postIds.length > 0) { + if (proposalType === ProposalType.TIPS) { + postsVariables['hash_in'] = postIds; + } else { + postsVariables['index_in'] = postIds; + } + } + + const subsquidRes = await fetchSubsquid({ + network, + query: GET_PROPOSALS_LISTING_BY_TYPE, + variables: postsVariables + }); + + const subsquidData = subsquidRes?.data; + const subsquidPosts: any[] = subsquidData?.proposals; + + const postsPromise = subsquidPosts?.map(async (subsquidPost): Promise => { + const { createdAt, end, hash, index, type, proposer, preimage, description, group, curator } = subsquidPost; + let otherPostProposer = ''; + if (group?.proposals?.length) { + group.proposals.forEach((obj: any) => { + if (!otherPostProposer) { + if (obj.proposer) { + otherPostProposer = obj.proposer; + } else if (obj?.preimage?.proposer) { + otherPostProposer = obj.preimage.proposer; + } + } + }); + } + const status = subsquidPost.status; + const postId = proposalType === ProposalType.TIPS? hash: index; + const postDocRef = postsByTypeRef(network, strProposalType as ProposalType).doc(String(postId)); + + const post_reactionsQuerySnapshot = await postDocRef.collection('post_reactions').get(); + const reactions = getReactions(post_reactionsQuerySnapshot); + const post_reactions = { + '👍': reactions['👍']?.count || 0, + '👎': reactions['👎']?.count || 0 + }; + + const commentsQuerySnapshot = await postDocRef.collection('comments').count().get(); + const postDoc = await postDocRef.get(); + if (postDoc && postDoc.exists) { + const data = postDoc.data(); + if (data) { + const proposer_address = getProposerAddressFromFirestorePostData(data, network); + const topic = data?.topic; + const topic_id = data?.topic_id; + return { + comments_count: commentsQuerySnapshot.data()?.count || 0, + created_at: createdAt, + curator, + description, + end, + hash, + method: preimage?.method, + post_id: postId, + post_reactions, + proposer: proposer || preimage?.proposer || otherPostProposer || proposer_address || curator, + status, + title: data?.title || null, + topic: topic? topic: isTopicIdValid(topic_id)? { + id: topic_id, + name: getTopicNameFromTopicId(topic_id) + }: topicFromType, + type: type || subsquidProposalType, + user_id: data?.user_id || 1 + }; + } + } + + return { + comments_count: commentsQuerySnapshot.data()?.count || 0, + created_at: createdAt, + curator, + description, + end: end, + hash: hash || null, + method: preimage?.method, + post_id: postId, + post_reactions, + proposer: proposer || preimage?.proposer || otherPostProposer || curator || null, + status: status, + title: '', + topic: topicFromType, + type: type || subsquidProposalType, + user_id: 1 + }; + }); + + const posts = await Promise.all(postsPromise); + + const data: IPostsListingResponse = { + count: Number(subsquidData?.proposalsConnection?.totalCount || 0), + posts + }; + + return { + data: JSON.parse(JSON.stringify(data)), + error: null, + status: 200 + }; + } catch (error) { + return { + data: null, + error: error.message || messages.API_FETCH_ERROR, + status: Number(error.name) || 500 + }; + } +} + +// expects optional proposalType, page and listingLimit +const handler: NextApiHandler = async (req, res) => { + const { page = 1, trackNo, trackStatus, proposalType = ProposalType.DEMOCRACY_PROPOSALS, sortBy = sortValues.NEWEST,listingLimit = LISTING_LIMIT } = req.query; + + const network = String(req.headers['x-network']); + if(!network || !isValidNetwork(network)) res.status(400).json({ error: 'Invalid network in request header' }); + const postIds = req.body.postIds; + const { data, error, status } = await getOnChainPosts({ + listingLimit, + network, + page, + postIds, + proposalType, + sortBy, + trackNo, + trackStatus + }); + + if(error || !data) { + res.status(status).json({ error: error || messages.API_FETCH_ERROR }); + }else { + res.status(status).json(data); + } +}; + +export default withErrorHandling(handler); \ No newline at end of file diff --git a/pages/api/v1/listing/posts-by-address.ts b/pages/api/v1/listing/posts-by-address.ts new file mode 100644 index 0000000000..e1eadc3ec5 --- /dev/null +++ b/pages/api/v1/listing/posts-by-address.ts @@ -0,0 +1,109 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import type { NextApiHandler } from 'next'; + +import withErrorHandling from '~src/api-middlewares/withErrorHandling'; +import { isValidNetwork } from '~src/api-utils'; +import { networkDocRef } from '~src/api-utils/firestore_refs'; +import { getFirestoreProposalType } from '~src/global/proposalType'; +import { GET_PROPOSALS_BY_PROPOSER_ADDRESS } from '~src/queries'; +import { IApiResponse } from '~src/types'; +import apiErrorWithStatusCode from '~src/util/apiErrorWithStatusCode'; +import fetchSubsquid from '~src/util/fetchSubsquid'; +import getEncodedAddress from '~src/util/getEncodedAddress'; +import messages from '~src/util/messages'; + +export interface IProposalsObj { + democracy: any[], + treasury: any[] +} + +export interface IPostsByAddressListingResponse { + proposals: IProposalsObj; +} + +interface IGetPostsByAddressParams { + network: string; + proposerAddress?: string | string[]; +} + +export async function getPostsByAddress(params: IGetPostsByAddressParams): Promise> { + try { + const { network, proposerAddress } = params; + if (!proposerAddress) { + throw apiErrorWithStatusCode(`The proposerAddress "${proposerAddress}" is invalid.`, 400); + } + const netDocRef = networkDocRef(network); + const strProposalAddress = String(proposerAddress); + + const subsquidRes = await fetchSubsquid({ + network, + query: GET_PROPOSALS_BY_PROPOSER_ADDRESS, + variables: { + proposer_eq: getEncodedAddress(strProposalAddress, network) + } + }); + const proposalsObj: IProposalsObj = { + 'democracy': [], + 'treasury': [] + }; + const subsquidData = subsquidRes?.data; + const postTypesColRef = netDocRef.collection('post_types'); + const proposalsPromise = (subsquidData?.proposalsConnection?.edges as any[])?.map(async (edge) => { + if (edge) { + const node = edge?.node; + if (node) { + let title = ''; + const firestoreProposalType = getFirestoreProposalType(node.type); + const key = firestoreProposalType.replace('_proposals', '') as 'democracy' | 'treasury'; + const proposalDocSnapshot = await postTypesColRef.doc(firestoreProposalType).collection('posts').doc(String(node.index)).get(); + if (proposalDocSnapshot && proposalDocSnapshot.exists) { + const data = proposalDocSnapshot.data(); + title = data?.title; + } + const newProposal = { + ...node, + title + }; + proposalsObj[key].push(newProposal); + } + } + }); + await Promise.all(proposalsPromise); + return { + data: JSON.parse(JSON.stringify(proposalsObj)), + error: null, + status: 200 + }; + } catch (error) { + return { + data: null, + error: error.message || messages.API_FETCH_ERROR, + status: Number(error.name) || 500 + }; + } +} + +// expects proposerAddress +const handler: NextApiHandler = async (req, res) => { + const { proposerAddress } = req.query; + + const network = String(req.headers['x-network']); + if(!network || !isValidNetwork(network)) res.status(400).json({ error: 'Invalid network in request header' }); + const { data, error, status } = await getPostsByAddress({ + network, + proposerAddress + }); + + if(error || !data) { + res.status(status).json({ error: error || messages.API_FETCH_ERROR }); + }else { + res.status(status).json({ + proposals: data + }); + } +}; + +export default withErrorHandling(handler); \ No newline at end of file diff --git a/pages/api/v1/listing/preimages.ts b/pages/api/v1/listing/preimages.ts new file mode 100644 index 0000000000..fe1426cddc --- /dev/null +++ b/pages/api/v1/listing/preimages.ts @@ -0,0 +1,89 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import { NextApiHandler } from 'next'; + +import withErrorHandling from '~src/api-middlewares/withErrorHandling'; +import { isValidNetwork } from '~src/api-utils'; +import { LISTING_LIMIT } from '~src/global/listingLimit'; +import { GET_PREIMAGES_TABLE_QUERY } from '~src/queries'; +import { IApiResponse } from '~src/types'; +import apiErrorWithStatusCode from '~src/util/apiErrorWithStatusCode'; +import fetchSubsquid from '~src/util/fetchSubsquid'; +import messages from '~src/util/messages'; + +export interface IPreimagesListing {} + +export interface IPreimagesListingResponse { + count: number; + preimages: IPreimagesListing[]; +} + +interface IGetPreimagesParams { + network: string; + listingLimit: number | string | string []; + page: number | string | string []; +} + +export async function getPreimages(params: IGetPreimagesParams): Promise> { + try { + const { network, listingLimit, page } = params; + + const numListingLimit = Number(listingLimit); + if (isNaN(numListingLimit)) { + throw apiErrorWithStatusCode(`Invalid listingLimit "${listingLimit}"`, 400); + } + + const numPage = Number(page); + if (isNaN(numPage) || numPage <= 0) { + throw apiErrorWithStatusCode(`Invalid page "${page}"`, 400); + } + const subsquidRes = await fetchSubsquid({ + network, + query: GET_PREIMAGES_TABLE_QUERY, + variables: { + limit: numListingLimit, + offset: numListingLimit * (numPage - 1) + } + }); + + const subsquidData = subsquidRes?.data; + const data: IPreimagesListingResponse = { + count: Number(subsquidData?.preimagesConnection?.totalCount), + preimages: subsquidData?.preimages || [] + }; + return { + data: JSON.parse(JSON.stringify(data)), + error: null, + status: 200 + }; + } catch (error) { + return { + data: null, + error: error.message || messages.API_FETCH_ERROR, + status: Number(error.name) || 500 + }; + } +} + +const handler: NextApiHandler = async (req, res) => { + const { page = 1, listingLimit = LISTING_LIMIT } = req.query; + + const network = String(req.headers['x-network']); + if(!network || !isValidNetwork(network)) res.status(400).json({ error: 'Invalid network in request header' }); + + const { data, error, status } = await getPreimages({ + listingLimit, + network, + page + }); + + if(error || !data) { + res.status(status).json({ error: error || messages.API_FETCH_ERROR }); + }else { + res.status(status).json(data); + } +}; + +export default withErrorHandling(handler); \ No newline at end of file diff --git a/pages/api/v1/listing/user-posts.ts b/pages/api/v1/listing/user-posts.ts new file mode 100644 index 0000000000..bec26ec20b --- /dev/null +++ b/pages/api/v1/listing/user-posts.ts @@ -0,0 +1,361 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import type { NextApiHandler } from 'next'; + +import withErrorHandling from '~src/api-middlewares/withErrorHandling'; +import { isValidNetwork } from '~src/api-utils'; +import { postsByTypeRef } from '~src/api-utils/firestore_refs'; +import { MessageType } from '~src/auth/types'; +import messages from '~src/auth/utils/messages'; +import { getFirestoreProposalType, ProposalType } from '~src/global/proposalType'; +import { GET_ONCHAIN_POSTS_BY_PROPOSER_ADDRESSES } from '~src/queries'; +import { firestore_db } from '~src/services/firebaseInit'; +import { IApiResponse } from '~src/types'; +import apiErrorWithStatusCode from '~src/util/apiErrorWithStatusCode'; +import fetchSubsquid from '~src/util/fetchSubsquid'; +import getEncodedAddress from '~src/util/getEncodedAddress'; +import { IReaction } from '../posts/on-chain-post'; + +export interface IUserPost { + content: string; + created_at: Date; + id: string; + post_reactions: { + '👍': number; + '👎': number; + }; + proposer: string; + title: string; + type: ProposalType; + username?: string; + track_number?: number; +} + +export interface IUserPostsListingResponse { + gov1: { + discussions: { + posts: IUserPost[]; + }; + democracy: { + referenda: IUserPost[]; + proposals: IUserPost[]; + }; + treasury: { + treasury_proposals: IUserPost[]; + bounties: IUserPost[]; + tips: IUserPost[]; + }; + collective: { + council_motions: IUserPost[]; + tech_comm_proposals: IUserPost[]; + }; + }; + open_gov: { + discussions: { + posts: IUserPost[]; + }; + root: IUserPost[]; + staking_admin: IUserPost[]; + auction_admin: IUserPost[]; + governance: { + lease_admin: IUserPost[]; + general_admin: IUserPost[]; + referendum_canceller: IUserPost[]; + referendum_killer: IUserPost[]; + }; + treasury: { + treasurer: IUserPost[]; + small_tipper: IUserPost[]; + big_tipper: IUserPost[]; + small_spender: IUserPost[]; + medium_spender: IUserPost[]; + big_spender: IUserPost[]; + }; + fellowship: { + member_referenda: IUserPost[]; + whitelisted_caller: IUserPost[]; + fellowship_admin: IUserPost[]; + }; + } +} + +export const getDefaultUserPosts: () => IUserPostsListingResponse = () => { + return { + gov1: { + collective: { + council_motions: [], + tech_comm_proposals: [] + }, + democracy: { + proposals: [], + referenda: [] + }, + discussions: { + posts: [] + }, + treasury: { + bounties: [], + tips: [], + treasury_proposals: [] + } + }, + open_gov: { + auction_admin: [], + discussions: { + posts: [] + }, + fellowship: { + fellowship_admin: [], + member_referenda: [], + whitelisted_caller: [] + }, + governance: { + general_admin: [], + lease_admin: [], + referendum_canceller: [], + referendum_killer: [] + }, + root: [], + staking_admin: [], + treasury: { + big_spender: [], + big_tipper: [], + medium_spender: [], + small_spender: [], + small_tipper: [], + treasurer: [] + } + } + }; +}; + +interface IGetPostsByAddressParams { + network: string; + userId?: string | string[] | number; + addresses?: string | string[] | any[]; +} + +type TGetUserPosts = (params: IGetPostsByAddressParams) => Promise>; + +export const getUserPosts: TGetUserPosts = async (params) => { + try { + const { network, userId, addresses } = params; + if ((!userId && userId !== 0) && !addresses) { + throw apiErrorWithStatusCode('Missing parameters in request body', 400); + } + const numUserId = Number(userId); + if (isNaN(numUserId)) { + throw apiErrorWithStatusCode('UserId is invalid', 400); + } + + const userPosts = getDefaultUserPosts(); + + const userDoc = await firestore_db.collection('users').doc(String(numUserId)).get(); + let username = ''; + if (userDoc.exists && userDoc.data()) { + username = userDoc?.data()?.username; + } + const discussionsQuerySnapshot = await postsByTypeRef(network, ProposalType.DISCUSSIONS).where('user_id', '==', numUserId).get(); + const discussionsPromise = discussionsQuerySnapshot.docs.map(async (doc) => { + const data = doc.data(); + if (doc && doc.exists && data) { + const newData: IUserPost = { + content: data.content || '', + created_at: data?.created_at?.toDate() || null, + id: data.id, + post_reactions: { + '👍': 0, + '👎': 0 + }, + proposer: data.proposer_address || '', + title: data.title || '', + type: ProposalType.DISCUSSIONS, + username + }; + const postReactionsQuerySnapshot = await doc.ref.collection('post_reactions').get(); + postReactionsQuerySnapshot.docs.forEach((doc) => { + const data = doc.data(); + if (doc && doc.exists && data && data.reaction) { + const { reaction } = data; + if (['👍', '👎'].includes(reaction)) { + newData.post_reactions[reaction as IReaction]++; + } + } + }); + return newData; + } + }); + const discussionsPromiseSettledResult = await Promise.allSettled(discussionsPromise); + discussionsPromiseSettledResult.forEach((result) => { + if (result && result.status === 'fulfilled' && result.value) { + userPosts.gov1.discussions.posts.push(result.value); + userPosts.open_gov.discussions.posts.push(result.value); + } + }); + + const subsquidRes = await fetchSubsquid({ + network, + query: GET_ONCHAIN_POSTS_BY_PROPOSER_ADDRESSES, + variables: { + proposer_in: (addresses as string[])?.map((address) => getEncodedAddress(address, network)) || [] + } + }); + const edges = subsquidRes?.data?.proposalsConnection?.edges; + if (edges && Array.isArray(edges)) { + const onChainPostsPromise = (edges)?.map(async (edge) => { + if (edge && edge.node) { + const { type, hash, index, createdAt, description, proposalArguments, proposer, preimage, trackNumber } = edge.node; + const proposalType = getFirestoreProposalType(type); + const id = type === 'Tip'? hash: index; + const newData: IUserPost = { + content: description || ((proposalArguments && proposalArguments.description)? proposalArguments.description: ''), + created_at: createdAt || null, + id: id, + post_reactions: { + '👍': 0, + '👎': 0 + }, + proposer: proposer || ((preimage && preimage.proposer)? preimage.proposer: ''), + title: (preimage && preimage.method)? preimage.method: '', + track_number: trackNumber, + type: proposalType as ProposalType + }; + const doc = await postsByTypeRef(network, proposalType as any).doc(String(id)).get(); + const data = doc?.data(); + if (doc && doc.exists && data) { + if (data.created_at) { + newData.created_at = data?.created_at?.toDate(); + } + if (data.content) { + newData.content = data.content; + } + if (data.title) { + newData.title = data.title; + } + const postReactionsQuerySnapshot = await doc.ref.collection('post_reactions').get(); + postReactionsQuerySnapshot.docs.forEach((doc) => { + const data = doc.data(); + if (doc && doc.exists && data && data.reaction) { + const { reaction } = data; + if (['👍', '👎'].includes(reaction)) { + newData.post_reactions[reaction as IReaction]++; + } + } + }); + } + return newData; + } + }); + const onChainPostsPromiseSettledResult = await Promise.allSettled(onChainPostsPromise); + onChainPostsPromiseSettledResult.forEach((result) => { + if (result && result.status === 'fulfilled' && result.value) { + const value = result.value; + const type = value.type; + if (ProposalType.DEMOCRACY_PROPOSALS === type) { + userPosts.gov1.democracy.proposals.push(value); + } else if (ProposalType.REFERENDUMS === type) { + userPosts.gov1.democracy.referenda.push(value); + } else if (ProposalType.BOUNTIES === type) { + userPosts.gov1.treasury.bounties.push(value); + } else if (ProposalType.TIPS === type) { + userPosts.gov1.treasury.tips.push(value); + } else if (ProposalType.TREASURY_PROPOSALS === type) { + userPosts.gov1.treasury.treasury_proposals.push(value); + } else if (ProposalType.COUNCIL_MOTIONS === type) { + userPosts.gov1.collective.council_motions.push(value); + } else if (ProposalType.TECH_COMMITTEE_PROPOSALS === type) { + userPosts.gov1.collective.tech_comm_proposals.push(value); + } else if (ProposalType.REFERENDUM_V2 === type) { + const track_number = value.track_number; + if (track_number !== undefined && track_number !== null) { + switch(track_number) { + case 0: + userPosts.open_gov.root.push(value); + break; + case 1: + userPosts.open_gov.fellowship.whitelisted_caller.push(value); + break; + case 10: + userPosts.open_gov.staking_admin.push(value); + break; + case 11: + userPosts.open_gov.treasury.treasurer.push(value); + break; + case 12: + userPosts.open_gov.governance.lease_admin.push(value); + break; + case 13: + userPosts.open_gov.fellowship.fellowship_admin.push(value); + break; + case 14: + userPosts.open_gov.governance.general_admin.push(value); + break; + case 15: + userPosts.open_gov.auction_admin.push(value); + break; + case 20: + userPosts.open_gov.governance.referendum_canceller.push(value); + break; + case 21: + userPosts.open_gov.governance.referendum_killer.push(value); + break; + case 30: + userPosts.open_gov.treasury.small_tipper.push(value); + break; + case 31: + userPosts.open_gov.treasury.big_tipper.push(value); + break; + case 32: + userPosts.open_gov.treasury.small_spender.push(value); + break; + case 33: + userPosts.open_gov.treasury.medium_spender.push(value); + break; + case 34: + userPosts.open_gov.treasury.big_spender.push(value); + break; + } + } + } else if (ProposalType.FELLOWSHIP_REFERENDUMS === type) { + userPosts.open_gov.fellowship.member_referenda.push(value); + } + } + }); + } + return { + data: JSON.parse(JSON.stringify(userPosts)), + error: null, + status: 200 + }; + } catch (error) { + return { + data: null, + error: error.message || messages.API_FETCH_ERROR, + status: Number(error.name) || 500 + }; + } +}; + +// expects proposerAddress +const handler: NextApiHandler = async (req, res) => { + const network = String(req.headers['x-network']); + if(!network || !isValidNetwork(network)) res.status(400).json({ message: 'Invalid network in request header' }); + + const { userId, addresses } = req.body; + + const { data, error, status } = await getUserPosts({ + addresses, + network, + userId + }); + + if(error || !data) { + res.status(status).json({ message: error || messages.API_FETCH_ERROR }); + }else { + res.status(status).json(data); + } +}; + +export default withErrorHandling(handler); \ No newline at end of file diff --git a/pages/api/v1/network-socials/index.ts b/pages/api/v1/network-socials/index.ts new file mode 100644 index 0000000000..c899c165d0 --- /dev/null +++ b/pages/api/v1/network-socials/index.ts @@ -0,0 +1,61 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import { NextApiHandler } from 'next'; + +import withErrorHandling from '~src/api-middlewares/withErrorHandling'; +import { firestore_db } from '~src/services/firebaseInit'; +import { IApiResponse, NetworkSocials } from '~src/types'; +import apiErrorWithStatusCode from '~src/util/apiErrorWithStatusCode'; +import messages from '~src/util/messages'; + +interface IGetNetworkSocialsParams { + network: string; +} + +export async function getNetworkSocials(params: IGetNetworkSocialsParams): Promise> { + try { + const { network } = params; + const networkDoc = await firestore_db.collection('networks').doc(network).get(); + if(!networkDoc.exists) { + throw apiErrorWithStatusCode('Invalid network name', 400); + } + + const networkData = networkDoc.data(); + if(!networkData?.blockchain_socials) { + throw apiErrorWithStatusCode('No socials found for this network', 404); + } + + const networkSocials = networkData.blockchain_socials as NetworkSocials; + + return { + data: JSON.parse(JSON.stringify(networkSocials)), + error: null, + status: 200 + }; + } catch (error) { + return { + data: null, + error: error.message || messages.API_FETCH_ERROR, + status: Number(error.name) || 500 + }; + } +} + +const handler: NextApiHandler = async (req, res) => { + const network = req.headers['x-network'] as string; + if(!network) return res.status(400).json({ error: 'Missing network name in request headers' }); + + const { data, error, status } = await getNetworkSocials({ + network + }); + + if(error || !data) { + res.status(status).json({ error: error || messages.API_FETCH_ERROR }); + }else { + res.status(status).json(data); + } +}; + +export default withErrorHandling(handler); \ No newline at end of file diff --git a/pages/api/v1/polls/index.tsx b/pages/api/v1/polls/index.tsx new file mode 100644 index 0000000000..ddcb247fd7 --- /dev/null +++ b/pages/api/v1/polls/index.tsx @@ -0,0 +1,93 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. +import { NextApiHandler } from 'next'; + +import withErrorHandling from '~src/api-middlewares/withErrorHandling'; +import { isOffChainProposalTypeValid, isValidNetwork } from '~src/api-utils'; +import { postsByTypeRef } from '~src/api-utils/firestore_refs'; +import { MessageType } from '~src/auth/types'; +import POLL_TYPE, { isPollTypeValid } from '~src/global/pollTypes'; +import { ProposalType } from '~src/global/proposalType'; +import { IOptionPoll, IPoll } from '~src/types'; + +export function getPollCollectionName(pollType: string): string { + switch(pollType) { + case 'normal': + return 'polls'; + case 'option': + return 'option_polls'; + } + return ''; +} + +export interface IPollsResponse { + polls: IPoll[]; +} + +export interface IOptionPollsResponse { + optionPolls: IOptionPoll[]; +} + +const handler: NextApiHandler = async (req, res) => { + const { postId = null, pollType, proposalType } = req.query; + if (isNaN(Number(postId))) return res.status(400).json({ message: 'Invalid post ID.' }); + + const network = String(req.headers['x-network']); + if(!network || !isValidNetwork(network)) return res.status(400).json({ message: 'Invalid network in request header' }); + + const strProposalType = String(proposalType); + if (!isOffChainProposalTypeValid(strProposalType)) return res.status(400).json({ message: `The off chain proposal type of the name "${proposalType}" does not exist.` }); + + const strPollType = String(pollType); + if (!pollType || !isPollTypeValid(strPollType)) return res.status(400).json({ message: `The pollType "${pollType}" is invalid` }); + + const pollsQuery = await postsByTypeRef(network, strProposalType as ProposalType) + .doc(String(postId)) + .collection(getPollCollectionName(strPollType)) + .get(); + + if(pollsQuery.empty) return res.status(404).json({ message: 'No polls found for this post.' }); + + const polls: IPoll[] = []; + const optionPolls: IOptionPoll[] = []; + pollsQuery.forEach((poll) => { + if (poll.exists) { + const data = poll.data(); + if (data) { + if (strPollType === POLL_TYPE.OPTION) { + optionPolls.push({ + created_at: data.created_at, + end_at: data.end_at, + id: data.id, + option_poll_votes: data.option_poll_votes || [], + options: data.options || [], + question: data.question || '', + updated_at: data.updated_at + }); + } else if (strPollType === POLL_TYPE.NORMAL) { + polls.push({ + block_end: data.block_end, + created_at: data.created_at, + id: data.id, + poll_votes: data.poll_votes || [], + updated_at: data.updated_at + }); + } + } + } + }); + if (strPollType === POLL_TYPE.OPTION) { + res.status(200).json({ + optionPolls + }); + } else if (strPollType === POLL_TYPE.NORMAL) { + res.status(200).json({ + polls + }); + } else { + return res.status(400).json({ message: `The pollType "${pollType}" is invalid` }); + } +}; + +export default withErrorHandling(handler); \ No newline at end of file diff --git a/pages/api/v1/posts/off-chain-post.ts b/pages/api/v1/posts/off-chain-post.ts new file mode 100644 index 0000000000..df1459c2a1 --- /dev/null +++ b/pages/api/v1/posts/off-chain-post.ts @@ -0,0 +1,195 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import type { NextApiHandler } from 'next'; + +import withErrorHandling from '~src/api-middlewares/withErrorHandling'; +import { isFirestoreProposalTypeValid, isProposalTypeValid, isValidNetwork } from '~src/api-utils'; +import { postsByTypeRef } from '~src/api-utils/firestore_refs'; +import { getSubsquidProposalType, OffChainProposalType, ProposalType } from '~src/global/proposalType'; +import { GET_PROPOSAL_BY_INDEX_AND_TYPE_FOR_LINKING } from '~src/queries'; +import { IApiResponse } from '~src/types'; +import apiErrorWithStatusCode from '~src/util/apiErrorWithStatusCode'; +import fetchSubsquid from '~src/util/fetchSubsquid'; +import { getTopicFromType, getTopicNameFromTopicId, isTopicIdValid } from '~src/util/getTopicFromType'; +import messages from '~src/util/messages'; + +import { getProposerAddressFromFirestorePostData } from '../listing/on-chain-posts'; +import { getComments, getReactions, getTimeline, IPostResponse, isDataExist } from './on-chain-post'; + +interface IGetOffChainPostParams { + network: string; + postId?: string | string[] | number; + proposalType: OffChainProposalType | string | string[]; +} + +export const getUpdatedAt = (data: any) => { + let updated_at: Date | string | null = null; + if (data) { + if (data.last_edited_at) { + updated_at = data.last_edited_at?.toDate? data.last_edited_at.toDate(): data.last_edited_at; + } else if (data.updated_at) { + updated_at = data.updated_at?.toDate? data.updated_at?.toDate(): data.updated_at; + } + } + return updated_at; +}; + +export async function getOffChainPost(params: IGetOffChainPostParams) : Promise> { + try { + const { network, postId, proposalType } = params; + if (postId === undefined || postId === null) { + throw apiErrorWithStatusCode('Please send postId', 400); + } + + const strProposalType = String(proposalType); + if (!isFirestoreProposalTypeValid(strProposalType)) { + throw apiErrorWithStatusCode(`The off chain proposal type "${proposalType}" is invalid.`, 400); + } + + const postDocRef = postsByTypeRef(network, strProposalType as ProposalType).doc(String(postId)); + const discussionPostDoc = await postDocRef.get(); + if (!(discussionPostDoc && discussionPostDoc.exists)) { + throw apiErrorWithStatusCode(`The Post with id "${postId}" is not found.`, 400); + } + + // Post Reactions + const postReactionsQuerySnapshot = await postDocRef.collection('post_reactions').get(); + const post_reactions = getReactions(postReactionsQuerySnapshot); + + // Comments + const commentsSnapshot = await postDocRef.collection('comments').get(); + const comments = await getComments(commentsSnapshot, postDocRef); + + // Post Data + const data = discussionPostDoc.data(); + + const timeline = [ + { + created_at: data?.created_at?.toDate? data?.created_at?.toDate(): data?.created_at, + index: Number(postId), + statuses: [ + { + status: 'Created', + timestamp: data?.created_at?.toDate? data?.created_at?.toDate(): data?.created_at + } + ], + type: 'Discussions' + } + ]; + const topic = data?.topic; + const topic_id = data?.topic_id; + const proposer_address = getProposerAddressFromFirestorePostData(data, network); + const post_link = data?.post_link; + if (post_link) { + const { id, type } = post_link; + const postDocRef = postsByTypeRef(network, type).doc(String(id)); + const postDoc = await postDocRef.get(); + const postData = postDoc.data(); + if (postDoc.exists && postData) { + post_link.title = postData.title; + post_link.description = postData.content; + } + if (!post_link.title && !post_link.description) { + if (isProposalTypeValid(type)) { + const subsquidProposalType = getSubsquidProposalType(type as any); + const variables: any = { + type_eq: subsquidProposalType + }; + + if (type === ProposalType.TIPS) { + variables['hash_eq'] = String(id); + } else { + variables['index_eq'] = Number(id); + } + const subsquidRes = await fetchSubsquid({ + network, + query: GET_PROPOSAL_BY_INDEX_AND_TYPE_FOR_LINKING, + variables: variables + }); + const subsquidData = subsquidRes?.data; + if (!isDataExist(subsquidData)) { + throw apiErrorWithStatusCode(`The Post with id: "${id}" and type: "${type}" is not found.`, 400); + } + const post = subsquidData.proposals[0]; + const preimage = post?.preimage; + if (!post_link.title) { + post_link.title = preimage?.method; + } + if (!post_link.description) { + post_link.description = post.description || preimage?.proposedCall?.description; + } + if (post) { + const proposals = post?.group?.proposals; + if (proposals && Array.isArray(proposals)) { + timeline.push(...getTimeline(proposals)); + } + + if (timeline.length === 1) { + timeline.push(getTimeline([ + { + createdAt: postData?.createdAt, + hash: postData?.hash, + index: postData?.index, + statusHistory: postData?.statusHistory, + type: postData?.type + } + ])); + } + } + } + } + } + const post: IPostResponse = { + comments: comments, + content: data?.content, + created_at: data?.created_at?.toDate? data?.created_at?.toDate(): data?.created_at, + last_edited_at: getUpdatedAt(data), + post_id: data?.id, + post_link: post_link, + post_reactions: post_reactions, + proposer: proposer_address, + timeline: timeline, + title: data?.title, + topic: topic? topic: isTopicIdValid(topic_id)? { + id: topic_id, + name: getTopicNameFromTopicId(topic_id) + }: getTopicFromType(strProposalType as ProposalType), + user_id: data?.user_id, + username: data?.username + }; + return { + data: JSON.parse(JSON.stringify(post)), + error: null, + status: 200 + }; + } catch (error) { + return { + data: null, + error: error.message || messages.API_FETCH_ERROR, + status: Number(error.name) || 500 + }; + } +} + +// expects optional discussionType and postId of proposal +const handler: NextApiHandler = async (req, res) => { + const { postId = 0, proposalType = OffChainProposalType.DISCUSSIONS } = req.query; + + const network = String(req.headers['x-network']); + if(!network || !isValidNetwork(network)) res.status(400).json({ error: 'Invalid network in request header' }); + + const { data, error, status } = await getOffChainPost({ + network, + postId, + proposalType + }); + + if(error || !data) { + res.status(status).json({ error: error || messages.API_FETCH_ERROR }); + }else { + res.status(status).json(data); + } +}; +export default withErrorHandling(handler); \ No newline at end of file diff --git a/pages/api/v1/posts/on-chain-post.ts b/pages/api/v1/posts/on-chain-post.ts new file mode 100644 index 0000000000..424b593f07 --- /dev/null +++ b/pages/api/v1/posts/on-chain-post.ts @@ -0,0 +1,766 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import type { NextApiHandler } from 'next'; + +import withErrorHandling from '~src/api-middlewares/withErrorHandling'; +import { isProposalTypeValid, isValidNetwork } from '~src/api-utils'; +import { networkDocRef, postsByTypeRef } from '~src/api-utils/firestore_refs'; +import { getFirestoreProposalType, getProposalTypeTitle, getSubsquidProposalType, ProposalType, VoteType } from '~src/global/proposalType'; +import { GET_PROPOSAL_BY_INDEX_AND_TYPE } from '~src/queries'; +import { firestore_db } from '~src/services/firebaseInit'; +import { IApiResponse } from '~src/types'; +import apiErrorWithStatusCode from '~src/util/apiErrorWithStatusCode'; +import fetchSubsquid from '~src/util/fetchSubsquid'; +import getSubstrateAddress from '~src/util/getSubstrateAddress'; +import { getTopicFromType, getTopicNameFromTopicId, isTopicIdValid } from '~src/util/getTopicFromType'; +import messages from '~src/util/messages'; + +import { getProposerAddressFromFirestorePostData } from '../listing/on-chain-posts'; +import { getUpdatedAt } from './off-chain-post'; + +export const isDataExist = (data: any) => { + return (data && data.proposals && data.proposals.length > 0 && data.proposals[0]); +}; + +export const getTimeline = (proposals: any, isStatus?: { + swap: boolean; +}) => { + return proposals.map((obj: any) => { + const statuses = obj?.statusHistory as { status: string }[]; + if (obj.type === 'ReferendumV2') { + const index = statuses.findIndex((v) => v.status === 'DecisionDepositPlaced'); + if (index >= 0) { + const decidingIndex = statuses.findIndex((v) => v.status === 'Deciding'); + if (decidingIndex >= 0) { + const obj = statuses[index]; + statuses.splice(index, 1); + statuses.splice(decidingIndex, 0, obj); + if (isStatus) { + isStatus.swap = true; + } + } + } + } + return { + created_at: obj?.createdAt, + hash: obj?.hash, + index: obj?.index, + statuses, + type: obj?.type + }; + }) || []; +}; + +export interface IReactions { + '👍': { + count: number, + usernames: string[] + }; + '👎': { + count: number; + usernames: string[]; + }; +} + +export interface IPostResponse { + post_reactions: IReactions; + timeline: any[]; + comments: any[]; + content: string; + end?: number; + delay?: number; + vote_threshold?: any; + created_at?: string; + tippers?: any[]; + topic: { + id: number; + name: string; + }; + decision?: string; + last_edited_at?: string | Date | null; + [key: string]: any; +} + +export type IReaction = '👍' | '👎'; + +interface IGetOnChainPostParams { + network: string; + postId?: string | number | string[]; + voterAddress?: string | string[]; + proposalType: string | string[]; +} + +export function getDefaultReactionObj(): IReactions { + return { + '👍': { + count: 0, + usernames: [] + }, + '👎': { + count: 0, + usernames: [] + } + }; +} + +export function getReactions(reactionsQuerySnapshot: FirebaseFirestore.QuerySnapshot): IReactions { + const reactions = getDefaultReactionObj(); + reactionsQuerySnapshot.docs.forEach((doc) => { + if (doc && doc.exists) { + const data = doc.data(); + if (data) { + const { reaction, username } = data; + if (['👍', '👎'].includes(reaction)) { + reactions[reaction as IReaction].count++; + reactions[reaction as IReaction].usernames.push(username); + } + } + } + }); + return reactions; +} + +const getTopicFromFirestoreData = (data: any, proposalType: ProposalType) => { + if (data) { + const topic = data.topic; + const topic_id = data.topic_id; + return topic? topic: isTopicIdValid(topic_id)? { + id: topic_id, + name: getTopicNameFromTopicId(topic_id) + }: getTopicFromType(proposalType); + } + return null; +}; + +type TDocRef = FirebaseFirestore.DocumentReference; + +interface IParams { + data?: any; + id: string; + proposer?: string; + network: string; + proposalType: ProposalType; + timeline: any[]; +} + +const isDefaultStringExist = (str: string, proposalType: any) => { + const firstDefaultStr = `This is a ${getProposalTypeTitle(proposalType as ProposalType)}`; + const secondDefaultStr = 'Only this user can edit this description and the title. If you own this account, login and tell us more about your proposal'; + if (!proposalType) return true; + return str?.includes(firstDefaultStr) && str?.includes(secondDefaultStr); +}; + +const getAndSetNewData = async (params: IParams) => { + const { timeline, network, id, proposalType, data, proposer } = params; + + let newData: { + [key: string]: any; + } = { + content: '', + id: proposalType === ProposalType.TIPS? id: Number(id), + title: '' + }; + + if ((!data?.title || !data.content || isDefaultStringExist(data.content, proposalType.toString())) && timeline && Array.isArray(timeline) && timeline.length > 1) { + const resultDocList: TDocRef[] = []; + const created_at = new Date(); + const docRefMap: { + [key: string]: { + data?: any; + ref: TDocRef; + }; + } = {}; + + timeline.forEach((obj) => { + const firestorePostType = getFirestoreProposalType(obj.type) as ProposalType; + const postId = String(obj.index); + const postRef = postsByTypeRef(network, firestorePostType).doc(postId); + resultDocList.push(postRef); + docRefMap[postRef.path] = { + ref: postRef + }; + }); + + if (resultDocList.length > 0) { + const results = await firestore_db.getAll(...resultDocList); + if (results) { + results.forEach((result) => { + const path = result.ref.path; + const pathArr = path.split('/'); + let data: FirebaseFirestore.DocumentData | undefined; + if (result && result.exists) { + data = result.data(); + if (data) { + if (data?.title && !newData?.title) { + newData.title = data?.title; + } + if (data.content && !isDefaultStringExist(data.content, (pathArr.length > 3? pathArr[3]: '')) && !newData.content) { + newData.content = data.content; + newData.user_id = data.user_id || data.author_id; + } + if (!newData.proposer_address) { + newData.proposer_address = getProposerAddressFromFirestorePostData(data, network); + } + if (data.created_at && !newData.created_at) { + newData.created_at = data.created_at; + } + if (!newData.topic_id) { + newData.topic_id = getTopicFromFirestoreData(data, proposalType)?.id || null; + } + if (data.username && !newData.username) { + newData.username = data.username; + } + if (data.post_link && !newData.post_link) { + newData.post_link = data.post_link; + } + } + } + if (docRefMap[path]) { + docRefMap[path].data = data; + } else { + docRefMap[path] = { + data, + ref: result.ref + }; + } + }); + } + } + if (newData?.title && newData.content) { + const batch = firestore_db.batch(); + Object.entries(docRefMap).forEach(([key, value]) => { + if (!(key && value)) return; + // Getting post_types and postId from firestore doc path + const colDocNameArr = key.split('/'); + if (colDocNameArr.length >= 6) { + const pathPostType = colDocNameArr[3]; + const postId: string | number = colDocNameArr[5]; + // Constructing "dummy data" from existing "data" and "newData" + const dummyData = value.data? { + ...value.data, + content: (value.data.content && !isDefaultStringExist(value?.data?.content, pathPostType))? value.data.content: newData.content, + title: value.data?.title? value.data?.title: newData?.title, + user_id: newData.user_id? newData.user_id: value.data.user_id + }: { + ...newData, + id: postId + }; + if (pathPostType !== 'tips') { + const numPostId = Number(dummyData.id); + if (!isNaN(numPostId)) { + dummyData.id = numPostId; + } + } + + // Sanitization + if (dummyData) { + const realData: any = {}; + Object.entries(dummyData).forEach(([key, value]) => { + if (value !== undefined && value !== null) { + realData[key === 'author_id'? 'user_id': key] = value; + } + }); + if (!realData.created_at) { + realData.created_at = created_at; + } + const date = getUpdatedAt(realData); + if (!date) { + realData.last_edited_at = new Date(); + } else if (realData.updated_at) { + realData['last_edited_at'] = date; + delete realData['updated_at']; + } + if (!realData.proposer_address && proposer) { + realData.proposer_address = getSubstrateAddress(proposer); + } + if (realData.user_id && realData.id) { + batch.set(value.ref, realData, { merge: true }); + } + } + } + }); + batch.commit() + .then(() => {}) + .catch((err) => { + console.log('Error while creating posts of group', err); + }); + } + } else { + newData = data; + } + return newData; +}; + +export async function getComments(commentsSnapshot: FirebaseFirestore.QuerySnapshot, postDocRef: FirebaseFirestore.DocumentReference): Promise { + const commentsPromise = commentsSnapshot.docs.map(async (doc) => { + if (doc && doc.exists) { + const data = doc.data(); + const commentDocRef = postDocRef.collection('comments').doc(String(doc.id)); + const commentsReactionsSnapshot = await commentDocRef.collection('comment_reactions').get(); + const comment_reactions = getReactions(commentsReactionsSnapshot); + const comment = { + comment_reactions: comment_reactions, + content: data.content, + created_at: data.created_at?.toDate? data.created_at.toDate(): data.created_at, + id: data.id, + proposer: '', + replies: [] as any[], + updated_at: getUpdatedAt(data), + user_id: data.user_id || data.user_id, + username: data.username + }; + + const repliesSnapshot = await commentDocRef.collection('replies').get(); + repliesSnapshot.docs.forEach((doc) => { + if (doc && doc.exists) { + const data = doc.data(); + if (data) { + const { created_at, id, username, comment_id, content, user_id } = data; + comment.replies.push({ + comment_id, + content, + created_at: created_at?.toDate? created_at.toDate(): created_at, + id: id, + proposer: '', + updated_at: getUpdatedAt(data), + user_id: user_id || user_id, + username + }); + } + } + }); + + const idsSet = new Set(); + comment.replies.forEach((reply) => { + if (reply) { + const { user_id } = reply; + if (typeof user_id === 'number') { + idsSet.add(user_id); + } else { + const numUserId = Number(user_id); + if (!isNaN(numUserId)) { + idsSet.add(numUserId); + } + } + } + }); + + const newIds = Array.from(idsSet); + + if (newIds.length > 0) { + const newIdsLen = newIds.length; + let lastIndex = 0; + for (let i = 0; i < newIdsLen; i+=30) { + lastIndex = i; + const addressesQuery = await firestore_db.collection('addresses').where('user_id', 'in', newIds.slice(i, newIdsLen > (i + 30)? (i + 30): newIdsLen)).get(); + addressesQuery.docs.map((doc) => { + if (doc && doc.exists) { + const data = doc.data(); + comment.replies = comment.replies.map((v) => { + if (v && v.user_id == data.user_id) { + return { + ...v, + proposer: data.address + }; + } + return v; + }); + } + }); + } + lastIndex += 30; + if (lastIndex <= newIdsLen) { + const addressesQuery = await firestore_db.collection('addresses').where('user_id', 'in', newIds.slice(lastIndex, (lastIndex === newIdsLen)? (newIdsLen + 1): newIdsLen)).get(); + addressesQuery.docs.map((doc) => { + if (doc && doc.exists) { + const data = doc.data(); + comment.replies = comment.replies.map((v) => { + if (v && v.user_id == data.user_id) { + return { + ...v, + proposer: data.address + }; + } + return v; + }); + } + }); + } + } + return comment; + } + }); + let comments = await Promise.all(commentsPromise); + comments = comments.reduce((prev, comment) => { + if (comment) { + prev.push(comment); + } + return prev; + }, [] as any[]); + + const idsSet = new Set(); + comments.forEach((comment) => { + if (comment) { + const { user_id } = comment; + if (typeof user_id === 'number') { + idsSet.add(user_id); + } else { + const numUserId = Number(user_id); + if (!isNaN(numUserId)) { + idsSet.add(numUserId); + } + } + } + }); + + const newIds = Array.from(idsSet); + + if (newIds.length > 0) { + const newIdsLen = newIds.length; + let lastIndex = 0; + for (let i = 0; i < newIdsLen; i+=30) { + lastIndex = i; + const addressesQuery = await firestore_db.collection('addresses').where('user_id', 'in', newIds.slice(i, newIdsLen > (i + 30)? (i + 30): newIdsLen)).get(); + addressesQuery.docs.map((doc) => { + if (doc && doc.exists) { + const data = doc.data(); + comments = comments.map((v) => { + if (v && v.user_id == data.user_id) { + return { + ...v, + proposer: data.address + }; + } + return v; + }); + } + }); + } + if (lastIndex <= newIdsLen) { + const addressesQuery = await firestore_db.collection('addresses').where('user_id', 'in', newIds.slice(lastIndex, (lastIndex === newIdsLen)? (newIdsLen + 1): newIdsLen)).get(); + addressesQuery.docs.map((doc) => { + if (doc && doc.exists) { + const data = doc.data(); + comments = comments.map((v) => { + if (v && v.user_id == data.user_id) { + return { + ...v, + proposer: data.address + }; + } + return v; + }); + } + }); + } + } + + return comments; +} + +export async function getOnChainPost(params: IGetOnChainPostParams) : Promise> { + try { + const { network, postId, voterAddress, proposalType } = params; + const netDocRef = networkDocRef(network); + + const numPostId = Number(postId); + const strPostId = String(postId); + if (proposalType === ProposalType.TIPS) { + if (!strPostId) { + throw apiErrorWithStatusCode(`The Tip hash "${postId} is invalid."`, 400); + } + } else if (isNaN(numPostId) || numPostId < 0) { + throw apiErrorWithStatusCode(`The postId "${postId}" is invalid.`, 400); + } + + const strProposalType = String(proposalType) as ProposalType; + if (!isProposalTypeValid(strProposalType)) { + throw apiErrorWithStatusCode(`The proposal type "${proposalType}" is invalid.`, 400); + } + const topicFromType = getTopicFromType(proposalType as ProposalType); + + const subsquidProposalType = getSubsquidProposalType(proposalType as any); + + let postVariables: any = { + index_eq: numPostId, + type_eq: subsquidProposalType, + voter_eq: voterAddress? String(voterAddress): '' + }; + + const postQuery = GET_PROPOSAL_BY_INDEX_AND_TYPE; + if (proposalType === ProposalType.TIPS) { + postVariables = { + hash_eq: strPostId, + type_eq: subsquidProposalType + }; + } else if (proposalType === ProposalType.DEMOCRACY_PROPOSALS) { + postVariables['vote_type_eq'] = VoteType.DEMOCRACY_PROPOSAL; + } + const subsquidRes = await fetchSubsquid({ + network, + query: postQuery, + variables: postVariables + }); + + // Post + const subsquidData = subsquidRes?.data; + if (!isDataExist(subsquidData)) { + throw apiErrorWithStatusCode(`The Post with index "${postId}" is not found.`, 404); + } + + const postData = subsquidData.proposals[0]; + const preimage = postData?.preimage; + const proposalArguments = postData?.proposalArguments; + const proposedCall = preimage?.proposedCall; + const status = postData?.status; + const post: IPostResponse = { + bond: postData?.bond, + comments: [], + content: '', + created_at: postData?.createdAt, + curator: postData?.curator, + curator_deposit: postData?.curatorDeposit, + deciding: postData?.deciding, + decision_deposit_amount: postData?.decisionDeposit?.amount, + delay: postData?.delay, + deposit: postData?.deposit, + description: postData?.description, + enactment_after_block: postData?.enactmentAfterBlock, + enactment_at_block: postData?.enactmentAtBlock, + end: postData?.end, + ended_at: postData?.endedAt, + ended_at_block: postData?.endedAtBlock, + fee: postData?.fee, + hash: postData?.hash || preimage?.hash, + last_edited_at: postData?.updatedAt, + member_count: postData?.threshold?.value, + method: preimage?.method || proposedCall?.method || proposalArguments?.method, + motion_method: proposalArguments?.method, + origin: postData?.origin, + payee: postData?.payee, + post_id: postData?.index, + post_reactions: getDefaultReactionObj(), + proposal_arguments: proposalArguments, + proposed_call: proposedCall, + proposer: postData?.proposer || preimage?.proposer || postData?.curator, + requested: proposedCall?.args?.amount, + reward: postData?.reward, + status, + statusHistory: postData?.statusHistory, + submitted_amount: postData?.submissionDeposit?.amount, + tally: postData?.tally, + timeline: [], + topic: topicFromType, + track_number: postData?.trackNumber, + type: postData?.type || getSubsquidProposalType(proposalType as any), + vote_threshold: postData?.threshold?.type + }; + + const isStatus = { + swap: false + }; + // Timeline + const timelineProposals = postData?.group?.proposals || []; + post.timeline = getTimeline(timelineProposals, isStatus); + // Proposer and Curator address + if (timelineProposals && Array.isArray(timelineProposals)) { + for (let i = 0; i < timelineProposals.length; i++) { + if (post.proposer && post.curator) { + break; + } + const obj = timelineProposals[i]; + if (!post.proposer) { + if (obj.proposer) { + post.proposer = obj.proposer; + } else if (obj?.preimage?.proposer) { + post.proposer = obj.preimage.proposer; + } + } + if (!post.curator && obj.curator) { + post.curator = obj.curator; + } + } + } + if (!post.timeline || post.timeline.length === 0) { + post.timeline = getTimeline([ + { + createdAt: postData?.createdAt, + hash: postData?.hash, + index: postData?.index, + statusHistory: postData?.statusHistory, + type: postData?.type + } + ], isStatus); + } + + if (isStatus.swap) { + if (post.status === 'DecisionDepositPlaced') { + post.status = 'Deciding'; + } + } + + if (['referendums', 'open_gov'].includes(strProposalType) && voterAddress && postData?.votes?.[0]?.decision) { + post['decision'] = postData?.votes?.[0]?.decision; + } + + // deadline in treasury post + if (proposalType === ProposalType.TREASURY_PROPOSALS) { + post.deadline = null; + const eventSnapshot = await netDocRef.collection('events').where('post_id', '==', strPostId).limit(1).get(); + if (eventSnapshot.size > 0) { + const doc = eventSnapshot.docs[0]; + if (doc && doc.exists) { + post.deadline = doc.data().end_time || null; + } + } + } + + // Tippers + if (proposalType === ProposalType.TIPS) { + post.tippers = subsquidData?.tippersConnection?.edges?.reduce((tippers: any[], edge: any) => { + if (edge && edge?.node) { + tippers.push(edge.node); + } + return tippers; + }, []) || []; + } + + // Council motions votes + if (proposalType === ProposalType.COUNCIL_MOTIONS) { + post.motion_votes = subsquidData?.votesConnection?.edges?.reduce((motion_votes: any[], edge: any) => { + if (edge && edge?.node) { + motion_votes.push(edge.node); + } + return motion_votes; + }, []) || []; + } + + // Democracy proposals votes TotalCount + if (proposalType === ProposalType.DEMOCRACY_PROPOSALS) { + const numTotalCount = Number(subsquidData?.votesConnection?.totalCount); + post.seconds = isNaN(numTotalCount)? 0: numTotalCount; + } + + // Child Bounties + if (proposalType === ProposalType.BOUNTIES) { + post.child_bounties_count = subsquidData?.proposalsConnection?.totalCount || 0; + post.child_bounties = subsquidData?.proposalsConnection?.edges?.reduce((child_bounties: any[], edge: any) => { + if (edge && edge?.node) { + child_bounties.push(edge.node); + } + return child_bounties; + }, []) || []; + } + if (proposalType === ProposalType.CHILD_BOUNTIES) { + post.parent_bounty_index = postData?.parentBountyIndex; + } + + const postDocRef = postsByTypeRef(network, (strProposalType.toString() === 'open_gov'? ProposalType.REFERENDUM_V2: strProposalType)).doc(strPostId); + const firestorePost = await postDocRef.get(); + if (firestorePost) { + let data = firestorePost.data(); + // traverse the group, get and set the data. + data = await getAndSetNewData({ + data, + id: strPostId, + network, + proposalType: strProposalType, + proposer: post.proposer, + timeline: post?.timeline + }); + + // Populate firestore post data into the post object + if (data && post) { + post.topic = getTopicFromFirestoreData(data, strProposalType); + post.content = data.content; + if (!post.proposer) { + post.proposer = getProposerAddressFromFirestorePostData(data, network); + } + post.user_id = data.user_id; + post.title = data?.title; + post.last_edited_at = getUpdatedAt(data); + const post_link = data?.post_link; + if (post_link) { + const { id, type } = post_link; + const postDocRef = postsByTypeRef(network, type).doc(String(id)); + const postDoc = await postDocRef.get(); + const postData = postDoc.data(); + if (postDoc.exists && postData) { + post_link.title = postData?.title; + post_link.description = postData.content; + post_link.created_at = postData?.created_at?.toDate? postData?.created_at?.toDate(): postData?.created_at; + if (post.timeline && Array.isArray(post.timeline)) { + post.timeline.splice(0, 0, { + created_at: postData?.created_at?.toDate? postData?.created_at?.toDate(): postData?.created_at, + index: Number(id), + statuses: [ + { + status: 'Created', + timestamp: postData?.created_at?.toDate? postData?.created_at?.toDate(): postData?.created_at + } + ], + type: 'Discussions' + }); + } + } + } + post.post_link = post_link; + } + } + + // Comments + const commentsSnapshot = await postDocRef.collection('comments').get(); + post.comments = await getComments(commentsSnapshot, postDocRef); + + // Post Reactions + const postReactionsQuerySnapshot = await postDocRef.collection('post_reactions').get(); + post.post_reactions = getReactions(postReactionsQuerySnapshot); + + if (!post.content || post.content?.trim().length === 0) { + const proposer = post.proposer; + if (proposer) { + post.content = `This is a ${getProposalTypeTitle(proposalType as ProposalType)} whose proposer address (${proposer}) is shown in on-chain info below. Only this user can edit this description and the title. If you own this account, login and tell us more about your proposal.`; + } else { + post.content = `This is a ${getProposalTypeTitle(proposalType as ProposalType)}. Only the proposer can edit this description and the title. If you own this account, login and tell us more about your proposal.`; + } + } + + return { + data: JSON.parse(JSON.stringify(post)), + error: null, + status: 200 + }; + } catch (error) { + return { + data: null, + error: error.message || messages.API_FETCH_ERROR, + status: Number(error.name) || 500 + }; + } +} + +// expects optional proposalType and postId of proposal +const handler: NextApiHandler = async (req, res) => { + const { postId = 0, proposalType = ProposalType.DEMOCRACY_PROPOSALS, voterAddress } = req.query; + + // TODO: take proposalType and postId in dynamic pi route + + const network = String(req.headers['x-network']); + if(!network || !isValidNetwork(network)) res.status(400).json({ error: 'Invalid network in request header' }); + const { data, error, status } = await getOnChainPost({ + network, + postId, + proposalType, + voterAddress + }); + + if(error || !data) { + res.status(status).json({ error: error || messages.API_FETCH_ERROR }); + }else { + res.status(status).json(data); + } +}; + +export default withErrorHandling(handler); \ No newline at end of file diff --git a/pages/api/v1/users/username-exist.ts b/pages/api/v1/users/username-exist.ts new file mode 100644 index 0000000000..abf62088e4 --- /dev/null +++ b/pages/api/v1/users/username-exist.ts @@ -0,0 +1,26 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import type { NextApiHandler } from 'next'; +import withErrorHandling from '~src/api-middlewares/withErrorHandling'; +import { MessageType } from '~src/auth/types'; +import { firestore_db } from '~src/services/firebaseInit'; + +export interface IUsernameExistResponse { + isExist: boolean; +} + +const handler: NextApiHandler = async (req, res) => { + const { username } = req.query; + if (!username) { + return res.status(400).json({ message: `Invalid username ${username}.` }); + } + const users = await firestore_db.collection('users').where('username', '==', username).limit(1).get(); + let isExist = true; + if (users.size === 0) { + isExist = false; + } + res.status(200).json({ isExist }); +}; +export default withErrorHandling(handler); \ No newline at end of file diff --git a/pages/api/v1/votes/history.ts b/pages/api/v1/votes/history.ts new file mode 100644 index 0000000000..208e5aac1e --- /dev/null +++ b/pages/api/v1/votes/history.ts @@ -0,0 +1,126 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. +import type { NextApiRequest, NextApiResponse } from 'next'; + +import withErrorHandling from '~src/api-middlewares/withErrorHandling'; +import { isValidNetwork } from '~src/api-utils'; +import { MessageType } from '~src/auth/types'; +import { VOTES_LISTING_LIMIT } from '~src/global/listingLimit'; +import { TSubsquidProposalType, VoteType } from '~src/global/proposalType'; +import { VOTING_HISTORY_BY_VOTER_ADDRESS } from '~src/queries'; +import { IApiResponse } from '~src/types'; +import apiErrorWithStatusCode from '~src/util/apiErrorWithStatusCode'; +import fetchSubsquid from '~src/util/fetchSubsquid'; +import messages from '~src/util/messages'; + +export enum EDecision { + YES = 'yes', + NO = 'no', + ABSTAIN = 'abstain' +} + +export interface IVoteHistory { + decision: EDecision; + type: VoteType; + blockNumber: number; + index: number; + proposalType: TSubsquidProposalType; +} + +export interface IVotesHistoryResponse { + count: number; + votes: IVoteHistory[]; +} +export interface IGetVotesHistoryParams { + network: string; + listingLimit?: string | string[] | number; + page?: string | string[] | number; + voterAddress?: string | string[]; +} +export async function getVotesHistory(params: IGetVotesHistoryParams): Promise> { + try { + const { voterAddress, network, listingLimit, page } = params; + if (!voterAddress) { + throw apiErrorWithStatusCode(`Voter address ${voterAddress} can't be empty`, 400); + } + + const numListingLimit = Number(listingLimit); + if (isNaN(numListingLimit)) { + throw apiErrorWithStatusCode(`The listingLimit "${listingLimit}" is invalid.`, 400); + } + + const numPage = Number(page); + if (isNaN(numPage) || numPage <= 0) { + throw apiErrorWithStatusCode(`The page "${page}" is invalid.`, 400); + } + + const subsquidRes = await fetchSubsquid({ + network, + query: VOTING_HISTORY_BY_VOTER_ADDRESS, + variables: { + limit: numListingLimit, + offset: numListingLimit * (numPage - 1), + voter_eq: String(voterAddress) + } + }); + const subsquidData = subsquidRes?.data; + if (!subsquidData || !subsquidData?.votes) { + throw apiErrorWithStatusCode(`Votes history of voter "${voterAddress}" is not found.`, 404); + } + + const votes = subsquidData.votes; + const res: IVotesHistoryResponse = { + count: 0, + votes: [] + }; + const numCount = Number(subsquidData?.votesConnection?.totalCount); + if (!isNaN(numCount)) { + res.count = numCount; + } + if (votes && Array.isArray(votes)) { + votes.forEach((vote) => { + if (vote) { + res.votes.push({ + blockNumber: vote.blockNumber, + decision: vote.decision, + index: vote?.proposal?.index, + proposalType: vote?.proposal?.type, + type: vote.type + } as IVoteHistory); + } + }); + } + return { + data: JSON.parse(JSON.stringify(res)), + error: null, + status: 200 + }; + } catch (error) { + return { + data: null, + error: error.message || messages.API_FETCH_ERROR, + status: Number(error.name) || 500 + }; + } +} +async function handler (req: NextApiRequest, res: NextApiResponse) { + const { listingLimit = VOTES_LISTING_LIMIT, page = 0, voterAddress } = req.query; + + const network = String(req.headers['x-network']); + if(!network || !isValidNetwork(network)) res.status(400).json({ message: 'Invalid network in request header' }); + const { data, error, status } = await getVotesHistory({ + listingLimit, + network, + page, + voterAddress + }); + + if(error || !data) { + res.status(status).json({ message: error || messages.API_FETCH_ERROR }); + }else { + res.status(status).json(data); + } +} + +export default withErrorHandling(handler); \ No newline at end of file diff --git a/pages/api/v1/votes/index.ts b/pages/api/v1/votes/index.ts new file mode 100644 index 0000000000..af85ab6ceb --- /dev/null +++ b/pages/api/v1/votes/index.ts @@ -0,0 +1,126 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. +import type { NextApiRequest, NextApiResponse } from 'next'; + +import withErrorHandling from '~src/api-middlewares/withErrorHandling'; +import { isValidNetwork } from '~src/api-utils'; +import { VOTES_LISTING_LIMIT } from '~src/global/listingLimit'; +import { VoteType, voteTypes } from '~src/global/proposalType'; +import { isVotesSortOptionsValid, votesSortValues } from '~src/global/sortOptions'; +import { GET_CONVICTION_VOTES_LISTING_BY_TYPE_AND_INDEX, GET_CONVICTION_VOTES_WITH_TXN_HASH_LISTING_BY_TYPE_AND_INDEX, GET_VOTES_LISTING_BY_TYPE_AND_INDEX } from '~src/queries'; +import fetchSubsquid from '~src/util/fetchSubsquid'; + +export interface IVotesResponse { + yes: { + count: number; + votes: any[]; + }; + no: { + count: number; + votes: any[]; + }; + abstain: { + count: number; + votes: any[]; + }; +} + +// expects optional id, page, voteType and listingLimit +async function handler (req: NextApiRequest, res: NextApiResponse) { + const { postId = 0, page = 1, voteType = VoteType.REFERENDUM, listingLimit = VOTES_LISTING_LIMIT, sortBy = votesSortValues.TIME } = req.query; + + const network = String(req.headers['x-network']); + if(!network || !isValidNetwork(network)) { + res.status(400).json({ error: 'Invalid network in request header' }); + } + + const numListingLimit = Number(listingLimit); + if (isNaN(numListingLimit)) { + res.status(400).json({ error: `The listingLimit "${listingLimit}" is invalid.` }); + } + + const strVoteType = String(voteType); + if (!voteTypes.includes(strVoteType)) { + return res.status(400).json({ error: `The voteType "${voteType}" is invalid.` }); + } + + const numPage = Number(page); + if (isNaN(numPage) || numPage <= 0) { + return res.status(400).json({ error: `The page "${page}" is invalid.` }); + } + + const numPostId = Number(postId); + if (isNaN(numPostId) || numPostId < 0) { + return res.status(400).json({ error: `The postId "${postId}" is invalid.` }); + } + + const isOpenGov = voteType === VoteType.REFERENDUM_V2; + + const strSortBy = String(sortBy); + if (!isVotesSortOptionsValid(strSortBy)) { + return res.status(400).json({ error: `The sortBy "${sortBy}" is invalid.` }); + } + const variables: any = { + index_eq: numPostId, + limit: numListingLimit, + offset: numListingLimit * (numPage - 1), + orderBy: strSortBy === votesSortValues.BALANCE? 'balance_value_DESC': isOpenGov? 'createdAtBlock_DESC':'timestamp_DESC', + type_eq: voteType + }; + + // if ayes count, votes (decision = 'ays', offset = 0 , limit 10) + + // if nays count, + + let votesQuery = GET_VOTES_LISTING_BY_TYPE_AND_INDEX; + if (voteType === VoteType.REFERENDUM_V2) { + votesQuery = GET_CONVICTION_VOTES_LISTING_BY_TYPE_AND_INDEX; + if (['moonbase', 'moonriver', 'moonbeam'].includes(network)) { + votesQuery = GET_CONVICTION_VOTES_WITH_TXN_HASH_LISTING_BY_TYPE_AND_INDEX; + } + } + + const decisions = ['yes', 'no', 'abstain']; + + const promiseResults = await Promise.allSettled(decisions.map((decision) => { + variables['decision_eq'] = decision; + return fetchSubsquid({ + network, + query: votesQuery, + variables + }); + })); + + const resObj: IVotesResponse = { + abstain: { + count: 0, + votes: [] + }, + no: { + count: 0, + votes: [] + }, + yes: { + count: 0, + votes: [] + } + }; + + promiseResults.forEach((result, i) => { + const decision = i === 0? 'yes': i === 1? 'no': 'abstain'; + if (result && result.status === 'fulfilled' && result.value) { + const subsquidData = result.value?.data; + resObj[decision].votes = subsquidData?.votes; + resObj[decision].count = subsquidData?.votesConnection?.totalCount; + if (voteType === VoteType.REFERENDUM_V2) { + resObj[decision].votes = subsquidData?.convictionVotes; + resObj[decision].count = subsquidData?.convictionVotesConnection?.totalCount; + } + } + }); + + res.status(200).json(resObj); +} + +export default withErrorHandling(handler); \ No newline at end of file diff --git a/pages/auction-admin/index.tsx b/pages/auction-admin/index.tsx new file mode 100644 index 0000000000..7bb929e257 --- /dev/null +++ b/pages/auction-admin/index.tsx @@ -0,0 +1,121 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import { GetServerSideProps } from 'next'; +import { getOnChainPosts, IPostsListingResponse } from 'pages/api/v1/listing/on-chain-posts'; +import { IReferendumV2PostsByStatus } from 'pages/root'; +import React, { FC, useEffect } from 'react'; + +import { getNetworkFromReqHeaders } from '~src/api-utils'; +import TrackListing from '~src/components/Listing/Tracks/TrackListing'; +import { CustomStatus } from '~src/components/Listing/Tracks/TrackListingCard'; +import { useNetworkContext } from '~src/context'; +import { LISTING_LIMIT } from '~src/global/listingLimit'; +import { networkTrackInfo } from '~src/global/post_trackInfo'; +import { ProposalType } from '~src/global/proposalType'; +import SEOHead from '~src/global/SEOHead'; +import { sortValues } from '~src/global/sortOptions'; +import { IApiResponse, PostOrigin } from '~src/types'; +import { ErrorState } from '~src/ui-components/UIStates'; + +export const getServerSideProps: GetServerSideProps = async ({ req, query }) => { + const { page = 1, sortBy = sortValues.NEWEST } = query; + const network = getNetworkFromReqHeaders(req.headers); + + if(!networkTrackInfo[network][PostOrigin.AUCTION_ADMIN]) { + return { props: { error: `Invalid track for ${network}` } }; + } + + const { trackId } = networkTrackInfo[network][PostOrigin.AUCTION_ADMIN]; + const proposalType = ProposalType.OPEN_GOV; + + const fetches = { + all: getOnChainPosts({ + listingLimit: LISTING_LIMIT, + network, + page, + proposalType, + sortBy, + trackNo: trackId, + trackStatus: 'All' + }), + closed: getOnChainPosts({ + listingLimit: LISTING_LIMIT, + network, + page, + proposalType, + sortBy, + trackNo: trackId, + trackStatus: CustomStatus.Closed + }), + submitted: getOnChainPosts({ + listingLimit: LISTING_LIMIT, + network, + page, + proposalType, + sortBy, + trackNo: trackId, + trackStatus: CustomStatus.Submitted + }), + voting: getOnChainPosts({ + listingLimit: LISTING_LIMIT, + network, + page, + proposalType, + sortBy, + trackNo: trackId, + trackStatus: CustomStatus.Voting + }) + }; + + const responseArr = await Promise.allSettled(Object.values(fetches)); + + const results = responseArr.map((result) => { + if (result.status === 'fulfilled') { + return result.value; + } else { + return { + data: null, + error: result.reason + } as IApiResponse; + } + }); + const props: IAuctionAdminProps = { + network, + posts: {} + }; + Object.keys(fetches).forEach((key, index) => { + (props.posts as any)[key] = results[index]; + }); + + return { props }; +}; +interface IAuctionAdminProps { + posts: IReferendumV2PostsByStatus; + network: string; + error?: string; +} + +const AuctionAdmin: FC = (props) => { + const { posts, error } = props; + const { setNetwork } = useNetworkContext(); + + useEffect(() => { + setNetwork(props.network); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + if (error) return ; + + if (!posts || Object.keys(posts).length === 0) return null; + return <> + + + ; +}; + +export default AuctionAdmin; \ No newline at end of file diff --git a/pages/big-spender/index.tsx b/pages/big-spender/index.tsx new file mode 100644 index 0000000000..eb7813c409 --- /dev/null +++ b/pages/big-spender/index.tsx @@ -0,0 +1,121 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import { GetServerSideProps } from 'next'; +import { getOnChainPosts, IPostsListingResponse } from 'pages/api/v1/listing/on-chain-posts'; +import { IReferendumV2PostsByStatus } from 'pages/root'; +import React, { FC, useEffect } from 'react'; + +import { getNetworkFromReqHeaders } from '~src/api-utils'; +import TrackListing from '~src/components/Listing/Tracks/TrackListing'; +import { CustomStatus } from '~src/components/Listing/Tracks/TrackListingCard'; +import { useNetworkContext } from '~src/context'; +import { LISTING_LIMIT } from '~src/global/listingLimit'; +import { networkTrackInfo } from '~src/global/post_trackInfo'; +import { ProposalType } from '~src/global/proposalType'; +import SEOHead from '~src/global/SEOHead'; +import { sortValues } from '~src/global/sortOptions'; +import { IApiResponse, PostOrigin } from '~src/types'; +import { ErrorState } from '~src/ui-components/UIStates'; + +export const getServerSideProps: GetServerSideProps = async ({ req, query }) => { + const { page = 1, sortBy = sortValues.NEWEST } = query; + const network = getNetworkFromReqHeaders(req.headers); + + if(!networkTrackInfo[network][PostOrigin.BIG_SPENDER]) { + return { props: { error: `Invalid track for ${network}` } }; + } + + const { trackId } = networkTrackInfo[network][PostOrigin.BIG_SPENDER]; + const proposalType = ProposalType.OPEN_GOV; + + const fetches = { + all: getOnChainPosts({ + listingLimit: LISTING_LIMIT, + network, + page, + proposalType, + sortBy, + trackNo: trackId, + trackStatus: 'All' + }), + closed: getOnChainPosts({ + listingLimit: LISTING_LIMIT, + network, + page, + proposalType, + sortBy, + trackNo: trackId, + trackStatus: CustomStatus.Closed + }), + submitted: getOnChainPosts({ + listingLimit: LISTING_LIMIT, + network, + page, + proposalType, + sortBy, + trackNo: trackId, + trackStatus: CustomStatus.Submitted + }), + voting: getOnChainPosts({ + listingLimit: LISTING_LIMIT, + network, + page, + proposalType, + sortBy, + trackNo: trackId, + trackStatus: CustomStatus.Voting + }) + }; + + const responseArr = await Promise.allSettled(Object.values(fetches)); + + const results = responseArr.map((result) => { + if (result.status === 'fulfilled') { + return result.value; + } else { + return { + data: null, + error: result.reason + } as IApiResponse; + } + }); + const props: IBigSpenderProps = { + network, + posts: {} + }; + Object.keys(fetches).forEach((key, index) => { + (props.posts as any)[key] = results[index]; + }); + + return { props }; +}; +interface IBigSpenderProps { + posts: IReferendumV2PostsByStatus; + network: string; + error?: string; +} + +const BigSpender: FC = (props) => { + const { posts, error } = props; + const { setNetwork } = useNetworkContext(); + + useEffect(() => { + setNetwork(props.network); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + if (error) return ; + + if (!posts || Object.keys(posts).length === 0) return null; + return <> + + + ; +}; + +export default BigSpender; \ No newline at end of file diff --git a/pages/big-tipper/index.tsx b/pages/big-tipper/index.tsx new file mode 100644 index 0000000000..e3fe7f1da8 --- /dev/null +++ b/pages/big-tipper/index.tsx @@ -0,0 +1,121 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import { GetServerSideProps } from 'next'; +import { getOnChainPosts, IPostsListingResponse } from 'pages/api/v1/listing/on-chain-posts'; +import { IReferendumV2PostsByStatus } from 'pages/root'; +import React, { FC, useEffect } from 'react'; + +import { getNetworkFromReqHeaders } from '~src/api-utils'; +import TrackListing from '~src/components/Listing/Tracks/TrackListing'; +import { CustomStatus } from '~src/components/Listing/Tracks/TrackListingCard'; +import { useNetworkContext } from '~src/context'; +import { LISTING_LIMIT } from '~src/global/listingLimit'; +import { networkTrackInfo } from '~src/global/post_trackInfo'; +import { ProposalType } from '~src/global/proposalType'; +import SEOHead from '~src/global/SEOHead'; +import { sortValues } from '~src/global/sortOptions'; +import { IApiResponse, PostOrigin } from '~src/types'; +import { ErrorState } from '~src/ui-components/UIStates'; + +export const getServerSideProps: GetServerSideProps = async ({ req, query }) => { + const { page = 1, sortBy = sortValues.NEWEST } = query; + const network = getNetworkFromReqHeaders(req.headers); + + if(!networkTrackInfo[network][PostOrigin.BIG_TIPPER]) { + return { props: { error: `Invalid track for ${network}` } }; + } + + const { trackId } = networkTrackInfo[network][PostOrigin.BIG_TIPPER]; + const proposalType = ProposalType.OPEN_GOV; + + const fetches = { + all: getOnChainPosts({ + listingLimit: LISTING_LIMIT, + network, + page, + proposalType, + sortBy, + trackNo: trackId, + trackStatus: 'All' + }), + closed: getOnChainPosts({ + listingLimit: LISTING_LIMIT, + network, + page, + proposalType, + sortBy, + trackNo: trackId, + trackStatus: CustomStatus.Closed + }), + submitted: getOnChainPosts({ + listingLimit: LISTING_LIMIT, + network, + page, + proposalType, + sortBy, + trackNo: trackId, + trackStatus: CustomStatus.Submitted + }), + voting: getOnChainPosts({ + listingLimit: LISTING_LIMIT, + network, + page, + proposalType, + sortBy, + trackNo: trackId, + trackStatus: CustomStatus.Voting + }) + }; + + const responseArr = await Promise.allSettled(Object.values(fetches)); + + const results = responseArr.map((result) => { + if (result.status === 'fulfilled') { + return result.value; + } else { + return { + data: null, + error: result.reason + } as IApiResponse; + } + }); + const props: IBigTipperProps = { + network, + posts: {} + }; + Object.keys(fetches).forEach((key, index) => { + (props.posts as any)[key] = results[index]; + }); + + return { props }; +}; +interface IBigTipperProps { + posts: IReferendumV2PostsByStatus; + network: string; + error?: string; +} + +const BigTipper: FC = (props) => { + const { posts, error } = props; + const { setNetwork } = useNetworkContext(); + + useEffect(() => { + setNetwork(props.network); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + if (error) return ; + + if (!posts || Object.keys(posts).length === 0) return null; + return <> + + + ; +}; + +export default BigTipper; \ No newline at end of file diff --git a/pages/bounties/index.tsx b/pages/bounties/index.tsx new file mode 100644 index 0000000000..18752b4a23 --- /dev/null +++ b/pages/bounties/index.tsx @@ -0,0 +1,108 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import { Pagination } from 'antd'; +import { GetServerSideProps } from 'next'; +import { useRouter } from 'next/router'; +import { getOnChainPosts, IPostsListingResponse } from 'pages/api/v1/listing/on-chain-posts'; +import React, { FC, useEffect } from 'react'; + +import { getNetworkFromReqHeaders } from '~src/api-utils'; +import Listing from '~src/components/Listing'; +import { useNetworkContext } from '~src/context'; +import { LISTING_LIMIT } from '~src/global/listingLimit'; +import { ProposalType } from '~src/global/proposalType'; +import SEOHead from '~src/global/SEOHead'; +import { sortValues } from '~src/global/sortOptions'; +import { ErrorState } from '~src/ui-components/UIStates'; +import { handlePaginationChange } from '~src/util/handlePaginationChange'; + +export const getServerSideProps: GetServerSideProps = async ({ req, query }) => { + const { page = 1, sortBy = sortValues.NEWEST } = query; + const proposalType = ProposalType.BOUNTIES; + const network = getNetworkFromReqHeaders(req.headers); + const { data, error } = await getOnChainPosts({ + listingLimit: LISTING_LIMIT, + network, + page, + proposalType, + sortBy + }); + return { props: { data, error, network } }; +}; + +interface IBountiesProps { + data?: IPostsListingResponse; + error?: string; + network: string; +} + +const Bounties: FC = (props) => { + const { data, error } = props; + const router = useRouter(); + + const { setNetwork } = useNetworkContext(); + + useEffect(() => { + setNetwork(props.network); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + if (error) return ; + if (!data) return null; + const { posts, count } = data; + + const onPaginationChange = (page:number) => { + router.push({ + query:{ + page + } + }); + handlePaginationChange({ limit: LISTING_LIMIT, page }); + }; + + return ( + <> + +

On Chain Bounties

+ + {/* Intro and Create Post Button */} +
+

+ This is the place to discuss on-chain bounties. Bounty posts are automatically generated as soon as they are created on-chain. + Only the proposer is able to edit them. +

+
+ +
+
+

{ count } Bounties

+
+ +
+ +
+ { + !!count && count > 0 && count > LISTING_LIMIT && + + } +
+
+
+ + ); +}; + +export default Bounties; \ No newline at end of file diff --git a/pages/bounty/[id].tsx b/pages/bounty/[id].tsx new file mode 100644 index 0000000000..9be9bd73bb --- /dev/null +++ b/pages/bounty/[id].tsx @@ -0,0 +1,64 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import { GetServerSideProps } from 'next'; +import { getOnChainPost, IPostResponse } from 'pages/api/v1/posts/on-chain-post'; +import React, { FC, useEffect } from 'react'; +import Post from 'src/components/Post/Post'; +import { PostCategory } from 'src/global/post_categories'; +import BackToListingView from 'src/ui-components/BackToListingView'; +import { ErrorState, LoadingState } from 'src/ui-components/UIStates'; + +import { getNetworkFromReqHeaders } from '~src/api-utils'; +import { useNetworkContext } from '~src/context'; +import { noTitle } from '~src/global/noTitle'; +import { ProposalType } from '~src/global/proposalType'; +import SEOHead from '~src/global/SEOHead'; + +const proposalType = ProposalType.BOUNTIES; +export const getServerSideProps:GetServerSideProps = async ({ req, query }) => { + const { id } = query; + + const network = getNetworkFromReqHeaders(req.headers); + const { data, error } = await getOnChainPost({ + network, + postId: id, + proposalType + }); + return { props: { data, error, network } }; +}; + +interface IBountyPostProps { + data: IPostResponse; + error?: string; + network: string; +} +const BountyPost: FC = (props) => { + const { data: post, error } = props; + + const { setNetwork } = useNetworkContext(); + + useEffect(() => { + setNetwork(props.network); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + if (error) return ; + if (!post) return null; + + if (post) return (<> + + + + +
+ +
+ ); + + return
; + +}; + +export default BountyPost; diff --git a/pages/calendar/index.tsx b/pages/calendar/index.tsx new file mode 100644 index 0000000000..6b3aa96477 --- /dev/null +++ b/pages/calendar/index.tsx @@ -0,0 +1,1217 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import 'react-big-calendar/lib/css/react-big-calendar.css'; + +import type { MenuProps } from 'antd'; +import { Badge, Button, Col, Divider, Dropdown, Row, Space } from 'antd'; +import { dayjs } from 'dayjs-init'; +import { GetServerSideProps } from 'next'; +import Image from 'next/image'; +import React, { FC, useCallback, useContext, useEffect, useState } from 'react'; +import { Calendar, DateHeaderProps, dayjsLocalizer, View } from 'react-big-calendar'; +import SidebarRight from 'src/components/SidebarRight'; +import { UserDetailsContext } from 'src/context/UserDetailsContext'; +import { approvalStatus } from 'src/global/statuses'; +import { NetworkEvent, NotificationStatus } from 'src/types'; +import { Role } from 'src/types'; +import queueNotification from 'src/ui-components/QueueNotification'; +import styled from 'styled-components'; + +import chainLink from '~assets/chain-link.png'; +import { getNetworkFromReqHeaders } from '~src/api-utils'; +import { useNetworkContext } from '~src/context'; +import ErrorAlert from '~src/ui-components/ErrorAlert'; +import nextApiClientFetch from '~src/util/nextApiClientFetch'; + +import CreateEventSidebar from '../../src/components/Calendar/CreateEventSidebar'; +import CustomToolbar from '../../src/components/Calendar/CustomToolbar'; +import CustomToolbarMini from '../../src/components/Calendar/CustomToolbarMini'; +import CustomWeekHeader, { TimeGutterHeader } from '../../src/components/Calendar/CustomWeekHeader'; +import NetworkSelect from '../../src/components/Calendar/NetworkSelect'; + +interface ICalendarViewProps { + className?: string; + small?: boolean; + network: string; + emitCalendarEvents?: React.Dispatch> | undefined; +} + +const ALLOWED_ROLE = Role.EVENT_BOT; + +const localizer = dayjsLocalizer(dayjs); + +export const getServerSideProps: GetServerSideProps = async ({ req }) => { + + const network = getNetworkFromReqHeaders(req.headers); + return { + props: { + network + } + }; +}; + +const CalendarView: FC = (props) => { + const { className, small = false, emitCalendarEvents = undefined, network } = props; + const { setNetwork } = useNetworkContext(); + const [width, setWidth] = useState(0); + const [calLeftPanelWidth, setCalLeftPanelWidth] = useState(0); + + useEffect(() => { + setNetwork(network); + if (window) { + const width = window.innerWidth > 0 ? window.innerWidth : screen.width; + setWidth(width); + setCalLeftPanelWidth(document?.getElementById('calendar-left-panel')?.clientWidth); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // calculate #route-wrapper height with margin for sidebar. + + // for negative margin for toolbar + + const utcDate = new Date(new Date().toISOString().slice(0,-1)); + + const { id, allowed_roles } = useContext(UserDetailsContext); + let accessible = false; + if(allowed_roles && allowed_roles?.length > 0 && allowed_roles.includes(ALLOWED_ROLE)) { + accessible = true; + } + + const [error, setError] = useState(''); + const [calendarEvents, setCalendarEvents] = useState([]); + const [selectedView, setSelectedView] = useState('month'); + const [selectedNetwork, setSelectedNetwork] = useState(network); + const [selectedDate, setSelectedDate] = useState(new Date()); + const [miniCalSelectedDate, setMiniCalSelectedDate] = useState(new Date()); + const [sidebarEvent, setSidebarEvent] = useState(); + const [sidebarCreateEvent, setSidebarCreateEvent] = useState(false); + + const [queryApprovalStatus, setQueryApprovalStatus] = useState(approvalStatus.APPROVED); + + const [eventApprovalStatus, setEventApprovalStatus] = useState(queryApprovalStatus); + + const approvalStatusDropdown : MenuProps['items'] = [ + { + key: approvalStatus.APPROVED, + label: 'Approved' + }, + { + key: approvalStatus.PENDING, + label: 'Pending' + }, + { + key: approvalStatus.REJECTED, + label: 'Rejected' + } + ]; + + const getNetworkEvents = useCallback(async () => { + const { data , error: fetchError } = await nextApiClientFetch( 'api/v1/events', { + approval_status: queryApprovalStatus + }); + + if(fetchError || !data) { + console.log('error fetching events : ', fetchError); + setError(fetchError || 'Error in fetching events'); + } + + if(data) { + const eventsArr:any[] = []; + const eventDatesArr:string[] = []; + + data.forEach(eventObj => { + const eventDate = new Date(eventObj.end_time); + const currDate = new Date(); + if(eventDate.getTime() >= currDate.getTime()) { + eventsArr.push({ + content: eventObj.content, + end_time: dayjs(eventObj.end_time).toDate(), + id: eventObj.id, + location: eventObj.location, + start_time: dayjs(eventObj.end_time).toDate(), + status: eventObj.status, + title: eventObj.title, + url: eventObj.url + }); + const eventDateStr = dayjs(eventObj.end_time).format('L'); + eventDatesArr.push(eventDateStr); + } + }); + setCalendarEvents(eventsArr); + if(emitCalendarEvents) { + emitCalendarEvents(eventsArr); + } + } + }, [emitCalendarEvents, queryApprovalStatus]); + + useEffect(() => { + getNetworkEvents(); + }, [getNetworkEvents]); + + const togglePendingEvents = () => { + if(queryApprovalStatus != approvalStatus.APPROVED){ + setQueryApprovalStatus(approvalStatus.APPROVED); + }else { + setQueryApprovalStatus(approvalStatus.PENDING); + } + }; + + const onApprovalStatusChange : MenuProps['onClick'] = ({ key }) => { + const status = key as string; + setEventApprovalStatus(status); + }; + + const handleUpdateApproval = async () => { + if(!sidebarEvent || !eventApprovalStatus || Object.keys(sidebarEvent).length === 0){ + return; + } + + const { data , error: fetchError } = await nextApiClientFetch( 'api/v1/auth/actions/updateApprovalStatus', { + approval_status: queryApprovalStatus, + eventId: sidebarEvent.id + }); + + if(fetchError || !data) { + setError(fetchError || 'Error in fetching events'); + queueNotification({ + header: 'Error!', + message: 'Error updating event', + status: NotificationStatus.ERROR + }); + console.error('Error updating event :', fetchError); + } + + if(data) { + setError(''); + queueNotification({ + header: 'Success!', + message: 'Event updated successfully', + status: NotificationStatus.SUCCESS + }); + setCalendarEvents((prev) => { + return prev?.map((event) => { + if (event.id === sidebarEvent.id) { + event.status = queryApprovalStatus.toLowerCase(); + } + return { + ...event + }; + }) || []; + }); + } + }; + + function showEventSidebar(event: any) { + if(small){ + return; + } + + setEventApprovalStatus(queryApprovalStatus); + setSidebarEvent(event); + } + + const EventWrapperComponent = ({ event, children }: any) => { + const newChildren = { ...children }; + const newChildrenProps = { ...newChildren.props }; + const statusClassName = dayjs(event.end_time).isBefore(new Date()) ? 'overdue-border' : `${event.status?.toLowerCase()}-border`; + newChildrenProps.className = `${newChildrenProps.className} ${statusClassName}`; + newChildren.props = { ...newChildrenProps }; + return
{newChildren}
; + }; + + function Event({ event } : {event: any}) { + return ( + showEventSidebar(event)}> + { (!(small || width < 768)) && {dayjs(event.end_time).format('LT').toLowerCase()} } + {event.title} + + ); + } + + function showDay(date: Date) { + setSelectedDate(date); + setSelectedView('day'); + } + + function setMiniCalendarToToday(){ + setMiniCalSelectedDate(new Date()); + } + + const MonthDateComponentHeader = ({ date }: DateHeaderProps) => { + return ; + }; + + const listData = [ + { color:'#EA8612', label: 'Working' }, + { color:'#5BC044', label: 'Completed' }, + { color:'#FF0000', label: 'Overdue' } + ]; + + return ( + <> +
+ {error && } + + {accessible && +
+ +
+ } + + { !small &&
+

Calendar

+
+ +
+
+ } + +
+ + {!small && width > 992 && + +
+

Current Time: { dayjs(utcDate).format('D-MM-YY | h:mm a UTC') }

+ + null, + eventWrapper: EventWrapperComponent, + month: { + dateHeader: MonthDateComponentHeader + }, + toolbar: (props:any) => + }} + /> + +
Proposal Status:
+ + {listData.map((item) => ( + + ))} + +
+ + } + + 992 ? 16 : 24} className=' h-full' > + , + toolbar: (props:any) => , + week: { + header: (props:any) => + } + }} + formats={{ + timeGutterFormat: 'h A' + }} + onNavigate={setSelectedDate} + onView={setSelectedView} + views={{ + agenda: true, + day: true, + month: true, + week: true, + work_week: false + }} + /> + +
+
+
+ + {/* Event View Sidebar */} + {sidebarEvent && setSidebarEvent(false)}> +
+ {accessible && +
+ Status: + {eventApprovalStatus} + +
+ } +
+
+
+

{sidebarEvent.title}

+
+ +
+ +
+ {dayjs(sidebarEvent.end_time).format('MMMM D')} {dayjs(sidebarEvent.end_time).format('h:mm a')} +
+ + {sidebarEvent.content &&
+ {`${sidebarEvent.content.substring(0, 769)} ${sidebarEvent.content.length > 769 ? '...' : ''}`} + {sidebarEvent.content.length > 769 && <>
Show More} +
+ } + + + +
+

link Relevant Links

+ +
+
+
} + + {/* Create Event Sidebar */} + { sidebarCreateEvent && } + + + ); +}; + +export default styled(CalendarView)` +.event-bot-div { + width: 100%; + margin-bottom: 16px; + + .pending-events-btn { + margin-left: auto; + margin-right: auto; + background-color: #E5007A; + color: #fff; + width: 50%; + font-size: 16px; + } +} + +.approval-status-div { + display: flex; + align-items: center; + margin-bottom: 34px; + + span, .dropdown { + margin-right: 8px; + } + + .button { + background-color: #E5007A; + color: #fff; + font-size: 13px; + } +} + +.events-sidebar, .create-event-sidebar { + min-width: 250px; + width: 510px; + max-width: 35vw; + right: 0; + top: 6.5rem; + background: #fff; + z-index: 100; + padding: 40px 24px; + box-shadow: -5px 0 15px -12px #888; + + @media only screen and (max-width: 768px) { + max-width: 90vw; + top: 0; + padding: 40px 14px; + padding-top: 70px; + overflow-y: auto; + + h1 { + margin-top: 0; + } + + .sidebar-event-content { + padding-right: 10px; + } + } + + .d-flex { + display: flex !important; + } + + .event-sidebar-header { + justify-content: space-between; + + .status-icon { + margin-right: 9px; + height: 12px; + width: 12px; + border-radius: 50%; + background-color: #E5007A; + + &.overdue-color { + background-color: #FF0000; + } + + &.completed-color { + background-color: #5BC044; + } + + &.in_progress-color { + background-color: #EA8612; + } + } + + + } + + .sidebar-event-datetime { + margin-top: 14px; + margin-left: 25px; + + span { + &:first-child { + border-right: 2px #eee solid; + padding-right: 12px; + margin-right: 8px; + } + } + } + + .sidebar-event-content { + margin-top: 30px; + margin-left: 25px; + padding-right: 25px; + font-size: 16px; + line-height: 24px; + + a { + color: #E5007A; + } + } + + .divider { + margin-top: 35px; + margin-bottom: 35px; + } + + .sidebar-event-links { + img { + height: 24px; + width: 24px; + margin-right: 12px; + } + + h3 { + font-size: 20px; + display: flex; + align-items: center; + } + + .links-container { + padding-left: 37px; + a { + margin-top: 25px; + color: #848484; + word-break: break-all; + } + } + + } + +} + +.cal-heading-div { + display: flex; + align-items: center; + justify-content: space-between; + margin-right: 10px; + + .mobile-network-select { + display: none; + margin-top: 2rem; + color: #E5007A; + font-size: 14px; + + label { + display: none !important; + } + + .filter-by-chain-div { + background-color: #fff; + border-radius: 5px; + border: 1px solid #eee; + padding: 4px; + display: flex; + align-items: center; + } + + @media only screen and (max-width: 768px) { + display: flex; + align-items: center; + } + } + + @media only screen and (max-width: 768px) { + h1 { + margin-bottom: 0 !important; + } + } + + @media only screen and (min-width: 769px) { + h1 { + display: none; + } + } +} + +.calendar-left-panel { + padding-top: 95px; + background-color: #fff; + border-top-left-radius: 10px; + border-right: 1px solid #E8E8E8; + + .utc-time { + color: #646464; + font-size: 14px; + font-weight: 500; + margin-left: 3px; + } + + .events-calendar-mini { + height: 320px; + border: 2px solid #E8E8E8; + border-radius: 10px; + padding: 15px 8px; + margin-bottom: 24px; + + .custom-calendar-toolbar-mini { + display: flex; + justify-content: center; + align-items: center; + margin-bottom: 8px; + + .button { + background: #fff !important; + + i { + font-weight: 900; + } + } + + span { + width: 104px; + min-width: 104px; + max-width: 104px; + text-align: center; + font-weight: 500 !important; + margin-left: 4px; + margin-right: 4px; + } + } + + .rbc-month-header { + margin-bottom: 8px; + } + + .rbc-header { + span { + font-size: 10px; + font-weight: 400 !important; + text-transform: uppercase; + color: #bbb; + } + } + + .rbc-month-view, + .rbc-header, + .rbc-month-row, + .rbc-day-bg { + background: #fff; + border: none; + } + + .rbc-date-cell { + text-align: center !important; + + button { + font-size: 12px; + padding: 5px; + font-weight: 500 !important; + background: #fff; + border: 1px solid #fff; + border-radius: 50%; + cursor: pointer; + + &:hover { + background: #E8E8E8; + border: 1px solid #E8E8E8; + } + } + + &.rbc-off-range { + button { + color: #E8E8E8; + } + } + + &.rbc-now { + button { + background-color: #E6007A; + color: #fff; + border: 1px solid #E6007A; + border-radius: 50%; + height:30px; + width:30px; + } + } + } + + .custom-event-wrapper{ + display: flex; + justify-content: center; + align-items: center; + margin-left: -3px; + margin-top: -2px; + + .rbc-event { + background:#E6007A; + cursor: default; + padding: 0 !important; + width: 5px; + height: 5px; + border-radius: 50%; + border: 2px solid #E6007A; + + &.overdue-border { + background:#FF0000 !important; + border: 2px solid #FF0000; + } + + &.completed-border { + background:#5BC044 !important; + border: 2px solid #5BC044; + + } + + &.in_progress-border { + background:#EA8612 !important; + border: 2px solid #EA8612; + } + + &:focus { + outline: none; + } + } + } + } + + .font-medium text-md text-sidebarBlue { + margin-left: 8px; + color: #646464; + font-size: 14px; + font-weight: 500; + } + + .legend-list { + margin-left: 10px; + } + +} + +.events-calendar { + height: 88vh; + width: 99%; + max-width: 1920px; + + @media only screen and (max-width: 768px) { + width: 100%; + max-width: 100%; + padding: 1em 0 1em 0; + max-height: 650px; + } + + .rbc-toolbar { + @media only screen and (max-width: 576px) { + flex-direction: column; + + span { + margin-bottom: 1em; + } + } + } + + .rbc-show-more { + color: #E5007A; + margin-top: 6px; + } + + .custom-calendar-toolbar, + .rbc-month-view, + .rbc-time-view, + .rbc-agenda-view { + background: #fff; + border: none; + } + + .rbc-month-view, + .rbc-time-view, + .rbc-agenda-view { + padding: 10px 10px; + } + + .custom-calendar-toolbar { + height: 77px; + padding: 6px 26px; + border-top-right-radius: 10px; + border-bottom: 1px solid #E8E8E8; + display: flex; + align-items: center; + + .select-div { + &:nth-of-type(2) { + padding-left: 19px; + width: 115px; + min-width: 115px; + max-width: 115px; + } + + display: flex; + flex-direction: column; + justify-content: center; + height: 65px; + border-right: 1px solid #E8E8E8; + padding-right: 19px; + + label { + font-size: 14px; + margin-bottom: 8px; + } + + .dropdown { + color: #E5007A; + } + + &.filter-by-chain-div { + .dropdown { + display: flex; + align-items: center; + } + } + } + + .date-text { + margin-left: 24px; + margin-right: 16px; + font-size: 20px; + color: #787878; + width: 140px; + min-width: 140px; + max-width: 140px; + } + + .mobile-cal-nav { + display: flex; + margin-left: 2px; + + .button { + padding: 0 !important + } + } + + .button { + background: none; + padding: 8px; + font-size: 14px; + + &:hover { + background: #eee; + } + } + + span { + word-wrap: none; + white-space: nowrap; + } + + .search-btn { + margin-left: auto; + margin-right: 22px; + font-size: 20px; + } + + .right-actions { + display: flex; + align-items: center; + margin-left: auto; + + .today-btn { + /* margin-right: 22px; */ + border-radius: 5px; + font-size: 16px; + padding: 10px 20px !important; + + @media only screen and (max-width: 576px) { + margin-right: 8px; + } + } + + .btn-disabled { + border: rgba(229, 0, 122, 0.5) !important; + color: rgba(229, 0, 122, 0.5) !important; + cursor: default; + } + } + + + .create-event-btn { + border-radius: 5px; + border: solid 1px #E5007A; + color: #E5007A !important; + font-size: 16px; + padding: 10px 20px !important; + margin-right: 0 !important; + font-weight: 500; + + @media only screen and (max-width: 768px) { + margin-right: 8px; + } + } + + &.small { + height: auto; + padding: 10px 2%; + border-bottom: none; + justify-content: space-between; + border-top-left-radius: 0; + border-top-right-radius: 0; + + .actions-right { + display: flex; + align-items: center; + } + + .today-btn-img { + cursor: pointer; + margin-right: 8px; + } + + .select-month-dropdown, .select-view-dropdown { + padding-left: 5px !important; + border: 1px solid #eee; + border-radius: 5px; + padding: 2px; + font-size: 12px; + white-space: nowrap; + + .icon { + padding-right: 2px !important; + } + } + + .select-month-dropdown { + width: 50px; + min-width: 50px; + max-width: 50px; + } + + .year-text { + margin-right: 8px; + } + + .create-event-btn { + padding: 6px 6px !important; + font-size: 12px; + margin-left: 8px; + } + } + + } + + &.small { + .custom-calendar-toolbar { + margin-bottom: 2px !important; + } + + .rbc-month-view, + .rbc-time-view, + .rbc-agenda-view { + padding: 0 !important; + } + + .rbc-time-header-cell { + .rbc-header { + + &.rbc-today { + .week-header-text { + .day-num { + background-color: #E6007A; + color: #fff; + width: 24px; + height: 24px; + display: flex; + justify-content: center; + align-items: center; + border-radius: 50%; + } + } + } + + .week-header-text { + .day-num { + font-size: 14px; + } + } + } + } + + .rbc-date-cell { + button { + font-size: 12px; + font-weight: 500 !important; + } + } + } + + .rbc-month-header { + height: 44px; + display: flex; + align-items: center; + border-bottom: 2px solid #eee; + + .rbc-header { + font-size: 16px; + font-weight: 400 !important; + border: none !important; + text-align: left; + margin-left: 2px; + } + } + + .rbc-time-header-cell { + min-height: inherit; + + .rbc-header { + border-bottom: none; + border-left: none; + padding-top: 6px; + padding-bottom: 13px; + + .week-header-text { + height: min-content; + color: #787878; + font-family: 'Roboto' !important; + + .day-of-week { + text-transform: uppercase; + font-size: 12px; + margin-bottom: 8px; + font-weight: 500; + } + + .day-num { + font-size: 22px; + } + } + } + } + + .rbc-date-cell { + button { + font-size: 15px; + padding: 5px; + font-weight: 600 !important; + } + + &.rbc-now { + button { + background-color: #E6007A; + color: #fff; + border: 1px solid #E6007A; + border-radius: 50%; + height:32px; + width:32px; + } + } + } + + .rbc-time-header-content { + border-left: none; + } + + .rbc-off-range-bg { + background: #fff !important; + } + + .rbc-off-range { + color: #CFCFCF; + } + + .rbc-date-cell { + text-align: left; + padding: 5px 8px; + } + + .rbc-time-header-gutter { + display: flex; + align-items: end; + justify-content: center; + text-align: center; + font-weight: 400; + font-size: 12px; + color: #777777; + padding-bottom: 4px; + + .day-num { + display: flex; + align-items: center; + justify-content: center; + background: #E6007A; + color: #fff; + height: 26px; + width: 26px; + border-radius: 50%; + font-size: 14px; + } + } + + .rbc-timeslot-group { + padding-left: 10px; + padding-right: 10px; + font-size: 12px; + color: #777777; + } + + .rbc-month-row { + .rbc-day-bg.rbc-today { + border: 1px solid #E6007A; + background-color: #fff; + } + } + + .rbc-today { + background-color: rgba(229, 0, 122, 0.02); + + .week-header-text { + color: #E5007A !important; + } + } + + .rbc-events-container { + .custom-event-wrapper{ + .rbc-event { + border: 1px solid #E6007A; + border-left: 4px solid #E6007A; + display: flex; + justify-content: center; + padding-top: 4px; + + .rbc-event-label { + display: none; + } + + &.overdue-border { + border: 1px solid #FF0000 !important; + border-left: 4px solid #FF0000 !important; + } + + &.completed-border { + border: 1px solid #5BC044 !important; + border-left: 4px solid #5BC044 !important; + } + + &.in_progress-border { + border: 1px solid #EA8612 !important; + border-left: 4px solid #EA8612 !important; + } + } + } + } + + .custom-event-wrapper{ + .rbc-event { + background-color: #fff; + border-radius: 0; + color: #000; + font-weight: 500; + font-size: 12px; + border-left: 4px solid #E6007A; + + .event-container-span { + cursor: pointer; + } + + &.overdue-border { + border-left: 4px solid #FF0000 !important; + } + + &.completed-border { + border-left: 4px solid #5BC044 !important; + } + + &.in_progress-border { + border-left: 4px solid #EA8612 !important; + } + + .event-time { + margin-right: 5px; + font-weight: 400; + color: #747474; + } + + &:focus { + outline: none; + } + } + } + + + .rbc-current-time-indicator { + background-color: #E6007A; + } +} + +.pt-0 { + padding-top: 0 !important; +} + +`; diff --git a/pages/child_bounties/index.tsx b/pages/child_bounties/index.tsx new file mode 100644 index 0000000000..e801105646 --- /dev/null +++ b/pages/child_bounties/index.tsx @@ -0,0 +1,108 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. +import { Pagination } from 'antd'; +import { GetServerSideProps } from 'next'; +import { useRouter } from 'next/router'; +import { getOnChainPosts, IPostsListingResponse } from 'pages/api/v1/listing/on-chain-posts'; +import React, { FC, useEffect } from 'react'; + +import { getNetworkFromReqHeaders } from '~src/api-utils'; +import Listing from '~src/components/Listing'; +import { useNetworkContext } from '~src/context'; +import { LISTING_LIMIT } from '~src/global/listingLimit'; +import { ProposalType } from '~src/global/proposalType'; +import SEOHead from '~src/global/SEOHead'; +import { sortValues } from '~src/global/sortOptions'; +import { ErrorState } from '~src/ui-components/UIStates'; +import { handlePaginationChange } from '~src/util/handlePaginationChange'; + +export const getServerSideProps: GetServerSideProps = async ({ req, query }) => { + const { page = 1, sortBy = sortValues.NEWEST } = query; + const proposalType = ProposalType.CHILD_BOUNTIES; + const network = getNetworkFromReqHeaders(req.headers); + const { data, error } = await getOnChainPosts({ + listingLimit: LISTING_LIMIT, + network, + page, + proposalType, + sortBy + }); + return { props: { data, error, network } }; +}; + +interface IChildBountiesProps { + data?: IPostsListingResponse; + error?: string; + network: string; +} + +const ChildBounties: FC = (props) => { + const { data, error } = props; + + const { setNetwork } = useNetworkContext(); + + useEffect(() => { + setNetwork(props.network); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const router = useRouter(); + + if (error) return ; + if (!data) return null; + const { posts, count } = data; + + const onPaginationChange = (page:number) => { + router.push({ + query:{ + page + } + }); + handlePaginationChange({ limit: LISTING_LIMIT, page }); + }; + + return ( + <> + +

On Chain Child Bounties

+ + {/* Intro and Create Post Button */} +
+

+ This is the place to discuss on-chain child bounties. Child Bounty posts are automatically generated as soon as they are created on-chain. + Only the proposer is able to edit them. +

+
+ +
+
+

{ count } Child Bounties

+
+ +
+ +
+ { + !!count && count > 0 && count > LISTING_LIMIT && + + } +
+
+
+ + ); +}; + +export default ChildBounties; \ No newline at end of file diff --git a/pages/child_bounty/[id].tsx b/pages/child_bounty/[id].tsx new file mode 100644 index 0000000000..a40557345b --- /dev/null +++ b/pages/child_bounty/[id].tsx @@ -0,0 +1,64 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import { GetServerSideProps } from 'next'; +import { getOnChainPost, IPostResponse } from 'pages/api/v1/posts/on-chain-post'; +import React, { FC, useEffect } from 'react'; +import Post from 'src/components/Post/Post'; +import { PostCategory } from 'src/global/post_categories'; +import BackToListingView from 'src/ui-components/BackToListingView'; +import { ErrorState, LoadingState } from 'src/ui-components/UIStates'; + +import { getNetworkFromReqHeaders } from '~src/api-utils'; +import { useNetworkContext } from '~src/context'; +import { noTitle } from '~src/global/noTitle'; +import { ProposalType } from '~src/global/proposalType'; +import SEOHead from '~src/global/SEOHead'; + +const proposalType = ProposalType.CHILD_BOUNTIES; +export const getServerSideProps:GetServerSideProps = async ({ req, query }) => { + const { id } = query; + + const network = getNetworkFromReqHeaders(req.headers); + const { data, error } = await getOnChainPost({ + network, + postId: id, + proposalType + }); + return { props: { data, error, network } }; +}; + +interface IChildBountyPostProps { + data: IPostResponse; + error?: string; + network: string; +} +const ChildBountyPost: FC = (props) => { + const { data: post, error } = props; + + const { setNetwork } = useNetworkContext(); + + useEffect(() => { + setNetwork(props.network); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + if (error) return ; + if (!post) return null; + + if (post) return (<> + + + + +
+ +
+ ); + + return
; + +}; + +export default ChildBountyPost; diff --git a/pages/council-board/index.tsx b/pages/council-board/index.tsx new file mode 100644 index 0000000000..0974bea354 --- /dev/null +++ b/pages/council-board/index.tsx @@ -0,0 +1,31 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. +import { GetServerSideProps } from 'next'; +import React, { useEffect } from 'react'; + +import { getNetworkFromReqHeaders } from '~src/api-utils'; +import CouncilBoardContainer from '~src/components/CouncilBoard'; +import { useNetworkContext } from '~src/context'; +import SEOHead from '~src/global/SEOHead'; + +export const getServerSideProps: GetServerSideProps = async ({ req }) => { + const network = getNetworkFromReqHeaders(req.headers); + return { props: { network } }; +}; + +const CouncilBoard = (props : { network: string}) => { + const { setNetwork } = useNetworkContext(); + + useEffect(() => { + setNetwork(props.network); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return <> + + + ; +}; + +export default CouncilBoard; \ No newline at end of file diff --git a/pages/council/index.tsx b/pages/council/index.tsx new file mode 100644 index 0000000000..c7a456044e --- /dev/null +++ b/pages/council/index.tsx @@ -0,0 +1,41 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. +import { GetServerSideProps } from 'next'; +import React, { useEffect } from 'react'; +import MembersContainer from 'src/components/Listing/Members/MembersContainer'; + +import { getNetworkFromReqHeaders } from '~src/api-utils'; +import { useNetworkContext } from '~src/context'; +import SEOHead from '~src/global/SEOHead'; + +export const getServerSideProps: GetServerSideProps = async ({ req }) => { + const network = getNetworkFromReqHeaders(req.headers); + return { props: { network } }; +}; + +const Members = (props : { network: string }) => { + const { setNetwork } = useNetworkContext(); + + useEffect(() => { + setNetwork(props.network); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( + <> + +

Council

+ + {/* Intro and Create Post Button */} +
+

+ Council is the body of elected members that consists of several on-chain accounts. The Council can act as a representative for "passive" (non-voting) stakeholders. Council members have two main tasks: proposing referenda for the overall stakeholder group to vote on and cancelling malicious referenda. +

+
+ + + ); +}; + +export default Members; \ No newline at end of file diff --git a/pages/discussions/index.tsx b/pages/discussions/index.tsx new file mode 100644 index 0000000000..9bf36281bf --- /dev/null +++ b/pages/discussions/index.tsx @@ -0,0 +1,96 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import { GetServerSideProps } from 'next'; +import Link from 'next/link'; +import { getOffChainPosts } from 'pages/api/v1/listing/off-chain-posts'; +import { IPostsListingResponse } from 'pages/api/v1/listing/on-chain-posts'; +import React, { FC, useContext, useEffect } from 'react'; +import { UserDetailsContext } from 'src/context/UserDetailsContext'; + +import { getNetworkFromReqHeaders } from '~src/api-utils'; +import OffChainPostsContainer from '~src/components/Listing/OffChain/OffChainPostsContainer'; +import { useNetworkContext } from '~src/context'; +import { LISTING_LIMIT } from '~src/global/listingLimit'; +import { OffChainProposalType } from '~src/global/proposalType'; +import SEOHead from '~src/global/SEOHead'; +import { sortValues } from '~src/global/sortOptions'; +import { ErrorState } from '~src/ui-components/UIStates'; + +interface IDiscussionsProps { + data?: IPostsListingResponse; + error?: string; + network: string; +} + +export const getServerSideProps: GetServerSideProps = async ({ req, query }) => { + const { page = 1, sortBy = sortValues.COMMENTED } = query; + + if(!Object.values(sortValues).includes(sortBy.toString())) { + return { + redirect: { + destination: `/discussions?page=${page}&sortBy=${sortValues.COMMENTED}`, + permanent: false + } + }; + } + + const network = getNetworkFromReqHeaders(req.headers); + + const { data, error = '' } = await getOffChainPosts({ + listingLimit: LISTING_LIMIT, + network, + page: Number(page), + proposalType: OffChainProposalType.DISCUSSIONS, + sortBy: String(sortBy) + }); + + return { + props: { + data, + error, + network + } + }; +}; + +const Discussions: FC = (props) => { + const { data, error } = props; + const { setNetwork } = useNetworkContext(); + + useEffect(() => { + setNetwork(props.network); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const { id } = useContext(UserDetailsContext); + + if (error) return ; + if (!data) return null; + const { posts, count } = data; + + return ( + <> + + +

Latest Discussions

+ + {/* Intro and Create Post Button */} +
+
+

+ This is the place to discuss all things polkadot. Anyone can start a new discussion. +

+
+ + + +
+ + + + ); +}; + +export default Discussions; \ No newline at end of file diff --git a/pages/fellowship-admin/index.tsx b/pages/fellowship-admin/index.tsx new file mode 100644 index 0000000000..c1bc2d9eaa --- /dev/null +++ b/pages/fellowship-admin/index.tsx @@ -0,0 +1,121 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import { GetServerSideProps } from 'next'; +import { getOnChainPosts, IPostsListingResponse } from 'pages/api/v1/listing/on-chain-posts'; +import { IReferendumV2PostsByStatus } from 'pages/root'; +import React, { FC, useEffect } from 'react'; + +import { getNetworkFromReqHeaders } from '~src/api-utils'; +import TrackListing from '~src/components/Listing/Tracks/TrackListing'; +import { CustomStatus } from '~src/components/Listing/Tracks/TrackListingCard'; +import { useNetworkContext } from '~src/context'; +import { LISTING_LIMIT } from '~src/global/listingLimit'; +import { networkTrackInfo } from '~src/global/post_trackInfo'; +import { ProposalType } from '~src/global/proposalType'; +import SEOHead from '~src/global/SEOHead'; +import { sortValues } from '~src/global/sortOptions'; +import { IApiResponse, PostOrigin } from '~src/types'; +import { ErrorState } from '~src/ui-components/UIStates'; + +export const getServerSideProps: GetServerSideProps = async ({ req, query }) => { + const { page = 1, sortBy = sortValues.NEWEST } = query; + const network = getNetworkFromReqHeaders(req.headers); + + if(!networkTrackInfo[network][PostOrigin.FELLOWSHIP_ADMIN]) { + return { props: { error: `Invalid track for ${network}` } }; + } + + const { trackId } = networkTrackInfo[network][PostOrigin.FELLOWSHIP_ADMIN]; + const proposalType = ProposalType.OPEN_GOV; + + const fetches = { + all: getOnChainPosts({ + listingLimit: LISTING_LIMIT, + network, + page, + proposalType, + sortBy, + trackNo: trackId, + trackStatus: 'All' + }), + closed: getOnChainPosts({ + listingLimit: LISTING_LIMIT, + network, + page, + proposalType, + sortBy, + trackNo: trackId, + trackStatus: CustomStatus.Closed + }), + submitted: getOnChainPosts({ + listingLimit: LISTING_LIMIT, + network, + page, + proposalType, + sortBy, + trackNo: trackId, + trackStatus: CustomStatus.Submitted + }), + voting: getOnChainPosts({ + listingLimit: LISTING_LIMIT, + network, + page, + proposalType, + sortBy, + trackNo: trackId, + trackStatus: CustomStatus.Voting + }) + }; + + const responseArr = await Promise.allSettled(Object.values(fetches)); + + const results = responseArr.map((result) => { + if (result.status === 'fulfilled') { + return result.value; + } else { + return { + data: null, + error: result.reason + } as IApiResponse; + } + }); + const props: IFellowshipAdminProps = { + network, + posts: {} + }; + Object.keys(fetches).forEach((key, index) => { + (props.posts as any)[key] = results[index]; + }); + + return { props }; +}; +interface IFellowshipAdminProps { + posts: IReferendumV2PostsByStatus; + network: string; + error?: string; +} + +const FellowshipAdmin: FC = (props) => { + const { posts, error } = props; + const { setNetwork } = useNetworkContext(); + + useEffect(() => { + setNetwork(props.network); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + if (error) return ; + + if (!posts || Object.keys(posts).length === 0) return null; + return <> + + + ; +}; + +export default FellowshipAdmin; \ No newline at end of file diff --git a/pages/fellowship/index.tsx b/pages/fellowship/index.tsx new file mode 100644 index 0000000000..6e5fa91f50 --- /dev/null +++ b/pages/fellowship/index.tsx @@ -0,0 +1,42 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. +import { GetServerSideProps } from 'next'; +import { EMembersType } from 'pages/members'; +import React, { useEffect } from 'react'; + +import { getNetworkFromReqHeaders } from '~src/api-utils'; +import WhitelistMembersContainer from '~src/components/Listing/WhitelistMembers/WhitelistMembersContainer'; +import { useNetworkContext } from '~src/context'; +import SEOHead from '~src/global/SEOHead'; + +export const getServerSideProps: GetServerSideProps = async ({ req }) => { + const network = getNetworkFromReqHeaders(req.headers); + return { props: { network } }; +}; + +const FellowshipMembers = (props: { network: string }) => { + const { setNetwork } = useNetworkContext(); + + useEffect(() => { + setNetwork(props.network); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( + <> + +

Fellowship

+ + {/* Intro and Create Post Button */} +
+

+ Fellowship is a mostly self-governing expert body with a primary goal of representing the humans who embody and contain the technical knowledge base of the Polkadot network and protocol. +

+
+ + + ); +}; + +export default FellowshipMembers; \ No newline at end of file diff --git a/pages/general-admin/index.tsx b/pages/general-admin/index.tsx new file mode 100644 index 0000000000..486781351a --- /dev/null +++ b/pages/general-admin/index.tsx @@ -0,0 +1,121 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import { GetServerSideProps } from 'next'; +import { getOnChainPosts, IPostsListingResponse } from 'pages/api/v1/listing/on-chain-posts'; +import { IReferendumV2PostsByStatus } from 'pages/root'; +import React, { FC, useEffect } from 'react'; + +import { getNetworkFromReqHeaders } from '~src/api-utils'; +import TrackListing from '~src/components/Listing/Tracks/TrackListing'; +import { CustomStatus } from '~src/components/Listing/Tracks/TrackListingCard'; +import { useNetworkContext } from '~src/context'; +import { LISTING_LIMIT } from '~src/global/listingLimit'; +import { networkTrackInfo } from '~src/global/post_trackInfo'; +import { ProposalType } from '~src/global/proposalType'; +import SEOHead from '~src/global/SEOHead'; +import { sortValues } from '~src/global/sortOptions'; +import { IApiResponse, PostOrigin } from '~src/types'; +import { ErrorState } from '~src/ui-components/UIStates'; + +export const getServerSideProps: GetServerSideProps = async ({ req, query }) => { + const { page = 1, sortBy = sortValues.NEWEST } = query; + const network = getNetworkFromReqHeaders(req.headers); + + if(!networkTrackInfo[network][PostOrigin.GENERAL_ADMIN]) { + return { props: { error: `Invalid track for ${network}` } }; + } + + const { trackId } = networkTrackInfo[network][PostOrigin.GENERAL_ADMIN]; + const proposalType = ProposalType.OPEN_GOV; + + const fetches = { + all: getOnChainPosts({ + listingLimit: LISTING_LIMIT, + network, + page, + proposalType, + sortBy, + trackNo: trackId, + trackStatus: 'All' + }), + closed: getOnChainPosts({ + listingLimit: LISTING_LIMIT, + network, + page, + proposalType, + sortBy, + trackNo: trackId, + trackStatus: CustomStatus.Closed + }), + submitted: getOnChainPosts({ + listingLimit: LISTING_LIMIT, + network, + page, + proposalType, + sortBy, + trackNo: trackId, + trackStatus: CustomStatus.Submitted + }), + voting: getOnChainPosts({ + listingLimit: LISTING_LIMIT, + network, + page, + proposalType, + sortBy, + trackNo: trackId, + trackStatus: CustomStatus.Voting + }) + }; + + const responseArr = await Promise.allSettled(Object.values(fetches)); + + const results = responseArr.map((result) => { + if (result.status === 'fulfilled') { + return result.value; + } else { + return { + data: null, + error: result.reason + } as IApiResponse; + } + }); + const props: IGeneralAdminProps = { + network, + posts: {} + }; + Object.keys(fetches).forEach((key, index) => { + (props.posts as any)[key] = results[index]; + }); + + return { props }; +}; +interface IGeneralAdminProps { + posts: IReferendumV2PostsByStatus; + network: string; + error?: string; +} + +const GeneralAdmin: FC = (props) => { + const { posts, error } = props; + const { setNetwork } = useNetworkContext(); + + useEffect(() => { + setNetwork(props.network); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + if (error) return ; + + if (!posts || Object.keys(posts).length === 0) return null; + return <> + + + ; +}; + +export default GeneralAdmin; \ No newline at end of file diff --git a/pages/gov-2/index.tsx b/pages/gov-2/index.tsx new file mode 100644 index 0000000000..7df252f7c5 --- /dev/null +++ b/pages/gov-2/index.tsx @@ -0,0 +1,130 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import { Skeleton } from 'antd'; +import { GetServerSideProps } from 'next'; +import dynamic from 'next/dynamic'; +import { getLatestActivityAllPosts } from 'pages/api/v1/latest-activity/all-posts'; +import { getLatestActivityOffChainPosts } from 'pages/api/v1/latest-activity/off-chain-posts'; +import { getLatestActivityOnChainPosts } from 'pages/api/v1/latest-activity/on-chain-posts'; +import { getNetworkSocials } from 'pages/api/v1/network-socials'; +import React, { useEffect } from 'react'; +import Gov2LatestActivity from 'src/components/Gov2Home/Gov2LatestActivity'; +import AboutNetwork from 'src/components/Home/AboutNetwork'; +import News from 'src/components/Home/News'; +import UpcomingEvents from 'src/components/Home/UpcomingEvents'; + +import { getNetworkFromReqHeaders } from '~src/api-utils'; +import { useNetworkContext } from '~src/context'; +import { networkTrackInfo } from '~src/global/post_trackInfo'; +import { EGovType, OffChainProposalType, ProposalType } from '~src/global/proposalType'; +import SEOHead from '~src/global/SEOHead'; +import { IApiResponse, NetworkSocials } from '~src/types'; +import { ErrorState } from '~src/ui-components/UIStates'; + +const TreasuryOverview = dynamic(() => import('~src/components/Home/TreasuryOverview'), { + loading: () => , + ssr: false +}); + +interface Props { + networkSocialsData?: IApiResponse; + gov2LatestPosts: Object; + network: string; + error: string; +} + +export const getServerSideProps:GetServerSideProps = async ({ req }) => { + const LATEST_POSTS_LIMIT = 8; + + const network = getNetworkFromReqHeaders(req.headers); + const networkSocialsData = await getNetworkSocials({ network }); + + if(!networkTrackInfo[network]) { + return { props: { error: 'Network does not support OpenGov yet.' } }; + } + + const fetches = { + allGov2Posts: getLatestActivityAllPosts({ + govType: EGovType.OPEN_GOV, + listingLimit: LATEST_POSTS_LIMIT, + network + }), + discussionPosts: getLatestActivityOffChainPosts({ + listingLimit: LATEST_POSTS_LIMIT, + network, + proposalType: OffChainProposalType.DISCUSSIONS + }) + }; + + for (const trackName of Object.keys(networkTrackInfo[network])) { + fetches [trackName as keyof typeof fetches] = getLatestActivityOnChainPosts({ + listingLimit: LATEST_POSTS_LIMIT, + network, + proposalType: ProposalType.OPEN_GOV, + trackNo: networkTrackInfo[network][trackName].trackId + }); + } + + const responseArr = await Promise.all(Object.values(fetches)); + + const gov2LatestPosts = { + allGov2Posts: responseArr[Object.keys(fetches).indexOf('allGov2Posts')], + discussionPosts: responseArr[Object.keys(fetches).indexOf('discussionPosts')] + }; + + for (const trackName of Object.keys(networkTrackInfo[network])) { + (gov2LatestPosts as any)[trackName as keyof typeof gov2LatestPosts] = responseArr[Object.keys(fetches).indexOf(trackName as keyof typeof fetches)]; + } + + const props:Props = { + error: '', + gov2LatestPosts, + network, + networkSocialsData + }; + + return { props }; +}; + +const Gov2Home = ({ error, gov2LatestPosts, network, networkSocialsData } : Props) => { + const { setNetwork } = useNetworkContext(); + + useEffect(() => { + setNetwork(network); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [network]); + + if (error) return ; + + return ( + <> + + +
+ {networkSocialsData && } +
+ +
+ +
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+ + ); +}; + +export default Gov2Home; \ No newline at end of file diff --git a/pages/grant/[id].tsx b/pages/grant/[id].tsx new file mode 100644 index 0000000000..4a0e147be4 --- /dev/null +++ b/pages/grant/[id].tsx @@ -0,0 +1,54 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import type { GetServerSideProps } from 'next'; +import { getOffChainPost } from 'pages/api/v1/posts/off-chain-post'; +import { IPostResponse } from 'pages/api/v1/posts/on-chain-post'; +import React, { FC } from 'react'; +import { PostCategory } from 'src/global/post_categories'; +import BackToListingView from 'src/ui-components/BackToListingView'; +import { ErrorState, LoadingState } from 'src/ui-components/UIStates'; + +import { getNetworkFromReqHeaders } from '~src/api-utils'; +import Post from '~src/components/Post/Post'; +import { noTitle } from '~src/global/noTitle'; +import { OffChainProposalType, ProposalType } from '~src/global/proposalType'; +import SEOHead from '~src/global/SEOHead'; + +export const getServerSideProps: GetServerSideProps = async ({ req, query }) => { + const { id } = query; + + const network = getNetworkFromReqHeaders(req.headers); + const { data, error } = await getOffChainPost({ + network, + postId: id, + proposalType: OffChainProposalType.GRANTS + }); + return { props: { data, error } }; +}; + +interface IGrantPostProps { + data: IPostResponse; + error?: string; +} +const GrantPost: FC = (props) => { + const { data: post, error } = props; + + if (error) return ; + + if (post) return (<> + + + + +
+ +
+ ); + + return
; + +}; + +export default GrantPost; \ No newline at end of file diff --git a/pages/grant/create.tsx b/pages/grant/create.tsx new file mode 100644 index 0000000000..d2b38966d6 --- /dev/null +++ b/pages/grant/create.tsx @@ -0,0 +1,29 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. +import { Skeleton } from 'antd'; +import dynamic from 'next/dynamic'; +import React from 'react'; + +import { ProposalType } from '~src/global/proposalType'; +import SEOHead from '~src/global/SEOHead'; + +const CreatePost = dynamic(() => import('~src/components/Post/CreatePost'), { + loading: () =>
+ + + + + +
, + ssr: false +}); + +const Create = () => { + return <> + + + ; +}; + +export default Create; \ No newline at end of file diff --git a/pages/grants/index.tsx b/pages/grants/index.tsx new file mode 100644 index 0000000000..e4f4868014 --- /dev/null +++ b/pages/grants/index.tsx @@ -0,0 +1,98 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import { Button } from 'antd'; +import { GetServerSideProps } from 'next'; +import Link from 'next/link'; +import { getOffChainPosts } from 'pages/api/v1/listing/off-chain-posts'; +import { IPostsListingResponse } from 'pages/api/v1/listing/on-chain-posts'; +import { FC, useContext, useEffect } from 'react'; + +import { getNetworkFromReqHeaders } from '~src/api-utils'; +import OffChainPostsContainer from '~src/components/Listing/OffChain/OffChainPostsContainer'; +import { useNetworkContext } from '~src/context'; +import { UserDetailsContext } from '~src/context/UserDetailsContext'; +import { LISTING_LIMIT } from '~src/global/listingLimit'; +import { OffChainProposalType } from '~src/global/proposalType'; +import SEOHead from '~src/global/SEOHead'; +import { sortValues } from '~src/global/sortOptions'; +import { ErrorState } from '~src/ui-components/UIStates'; + +interface IGrantsProps { + data?: IPostsListingResponse; + error?: string; + network: string; +} + +export const getServerSideProps: GetServerSideProps = async ({ req, query }) => { + const { page = 1, sortBy = sortValues.NEWEST } = query; + + if(!Object.values(sortValues).includes(sortBy.toString())) { + return { + redirect: { + destination: `/grants?page=${page}&sortBy=${sortValues.NEWEST}`, + permanent: false + } + }; + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const network = getNetworkFromReqHeaders(req.headers); + + const { data, error = '' } = await getOffChainPosts({ + listingLimit: LISTING_LIMIT, + network, + page: Number(page), + proposalType: OffChainProposalType.GRANTS, + sortBy: String(sortBy) + }); + + return { + props: { + data, + error, + network + } + }; +}; + +const Grants: FC = (props) => { + const { data, error, network } = props; + const { setNetwork } = useNetworkContext(); + + useEffect(() => { + setNetwork(props.network); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const { id } = useContext(UserDetailsContext); + + if (error) return ; + if (!data) return null; + const { posts, count } = data; + + return ( + <> + +
+

Grants Discussion

+ + + +
+ + {/* Intro and Create Post Button */} +
+

+ This is the place to discuss grants for {network}. Anyone can start a new grants discussion. + {' '}Guidelines of the Interim Grants Program. +

+
+ + + + ); +}; + +export default Grants; \ No newline at end of file diff --git a/pages/index.tsx b/pages/index.tsx new file mode 100644 index 0000000000..ad2eec475c --- /dev/null +++ b/pages/index.tsx @@ -0,0 +1,164 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import 'dayjs-init'; + +import { Skeleton } from 'antd'; +import { GetServerSideProps } from 'next'; +import dynamic from 'next/dynamic'; +import { FC, useEffect } from 'react'; +import SEOHead from 'src/global/SEOHead'; + +import { getNetworkFromReqHeaders } from '~src/api-utils'; +import AboutNetwork from '~src/components/Home/AboutNetwork'; +import LatestActivity from '~src/components/Home/LatestActivity'; +import News from '~src/components/Home/News'; +import UpcomingEvents from '~src/components/Home/UpcomingEvents'; +import { useNetworkContext } from '~src/context'; +import { isGrantsSupported } from '~src/global/grantsNetworks'; +import { LATEST_POSTS_LIMIT } from '~src/global/listingLimit'; +import { isOpenGovSupported } from '~src/global/openGovNetworks'; +import { OffChainProposalType, ProposalType } from '~src/global/proposalType'; +import { IApiResponse, NetworkSocials } from '~src/types'; + +import { getLatestActivityAllPosts } from './api/v1/latest-activity/all-posts'; +import { getLatestActivityOffChainPosts } from './api/v1/latest-activity/off-chain-posts'; +import { getLatestActivityOnChainPosts, ILatestActivityPostsListingResponse } from './api/v1/latest-activity/on-chain-posts'; +import { getNetworkSocials } from './api/v1/network-socials'; + +export type ILatestActivityPosts = { + [key in ProposalType]?: IApiResponse; +} +interface IHomeProps { + networkSocialsData?: IApiResponse; + latestPosts: { + all?: IApiResponse; + } & ILatestActivityPosts; + network: string; +} + +export const getServerSideProps:GetServerSideProps = async ({ req }) => { + + const network = getNetworkFromReqHeaders(req.headers); + if(isOpenGovSupported(network) && !req.headers.referer) { + return { + props: {}, + redirect: { + destination: '/gov-2' + } + }; + } + + const networkSocialsData = await getNetworkSocials({ network }); + + const fetches = { + all: getLatestActivityAllPosts({ + listingLimit: LATEST_POSTS_LIMIT, + network + }), + discussions: getLatestActivityOffChainPosts({ + listingLimit: LATEST_POSTS_LIMIT, + network, + proposalType: OffChainProposalType.DISCUSSIONS + }), + // eslint-disable-next-line sort-keys + bounties: getLatestActivityOnChainPosts({ + listingLimit: LATEST_POSTS_LIMIT, + network, + proposalType: ProposalType.BOUNTIES + }), + council_motions: getLatestActivityOnChainPosts({ + listingLimit: LATEST_POSTS_LIMIT, + network, + proposalType: ProposalType.COUNCIL_MOTIONS + }), + democracy_proposals: getLatestActivityOnChainPosts({ + listingLimit: LATEST_POSTS_LIMIT, + network, + proposalType: ProposalType.DEMOCRACY_PROPOSALS + }), + referendums: getLatestActivityOnChainPosts({ + listingLimit: LATEST_POSTS_LIMIT, + network, + proposalType: ProposalType.REFERENDUMS + }), + tips: getLatestActivityOnChainPosts({ + listingLimit: LATEST_POSTS_LIMIT, + network, + proposalType: ProposalType.TIPS + }), + treasury_proposals: getLatestActivityOnChainPosts({ + listingLimit: LATEST_POSTS_LIMIT, + network, + proposalType: ProposalType.TREASURY_PROPOSALS + }) + }; + + if (isGrantsSupported(network)) { + (fetches as any)['grants'] = getLatestActivityOffChainPosts({ + listingLimit: LATEST_POSTS_LIMIT, + network, + proposalType: OffChainProposalType.GRANTS + }); + } + + const responseArr = await Promise.all(Object.values(fetches)); + const props: IHomeProps = { + latestPosts: {}, + network, + networkSocialsData + }; + Object.keys(fetches).forEach((key, index) => { + props.latestPosts[key as keyof typeof fetches] = responseArr[index]; + }); + + return { props }; +}; + +const TreasuryOverview = dynamic(() => import('~src/components/Home/TreasuryOverview'), { + loading: () => , + ssr: false +}); + +const Home: FC = ({ latestPosts, network, networkSocialsData }) => { + const { setNetwork } = useNetworkContext(); + + useEffect(() => { + setNetwork(network); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( + <> + +
+
+ {networkSocialsData && } +
+ +
+ +
+ +
+ +
+ +
+ {network !== 'collectives' && +
+ +
+ } + +
+ +
+
+
+ + ); +}; + +export default Home; diff --git a/pages/lease-admin/index.tsx b/pages/lease-admin/index.tsx new file mode 100644 index 0000000000..c733d75e9b --- /dev/null +++ b/pages/lease-admin/index.tsx @@ -0,0 +1,121 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import { GetServerSideProps } from 'next'; +import { getOnChainPosts, IPostsListingResponse } from 'pages/api/v1/listing/on-chain-posts'; +import { IReferendumV2PostsByStatus } from 'pages/root'; +import React, { FC, useEffect } from 'react'; + +import { getNetworkFromReqHeaders } from '~src/api-utils'; +import TrackListing from '~src/components/Listing/Tracks/TrackListing'; +import { CustomStatus } from '~src/components/Listing/Tracks/TrackListingCard'; +import { useNetworkContext } from '~src/context'; +import { LISTING_LIMIT } from '~src/global/listingLimit'; +import { networkTrackInfo } from '~src/global/post_trackInfo'; +import { ProposalType } from '~src/global/proposalType'; +import SEOHead from '~src/global/SEOHead'; +import { sortValues } from '~src/global/sortOptions'; +import { IApiResponse, PostOrigin } from '~src/types'; +import { ErrorState } from '~src/ui-components/UIStates'; + +export const getServerSideProps: GetServerSideProps = async ({ req, query }) => { + const { page = 1, sortBy = sortValues.NEWEST } = query; + const network = getNetworkFromReqHeaders(req.headers); + + if(!networkTrackInfo[network][PostOrigin.LEASE_ADMIN]) { + return { props: { error: `Invalid track for ${network}` } }; + } + + const { trackId } = networkTrackInfo[network][PostOrigin.LEASE_ADMIN]; + const proposalType = ProposalType.OPEN_GOV; + + const fetches = { + all: getOnChainPosts({ + listingLimit: LISTING_LIMIT, + network, + page, + proposalType, + sortBy, + trackNo: trackId, + trackStatus: 'All' + }), + closed: getOnChainPosts({ + listingLimit: LISTING_LIMIT, + network, + page, + proposalType, + sortBy, + trackNo: trackId, + trackStatus: CustomStatus.Closed + }), + submitted: getOnChainPosts({ + listingLimit: LISTING_LIMIT, + network, + page, + proposalType, + sortBy, + trackNo: trackId, + trackStatus: CustomStatus.Submitted + }), + voting: getOnChainPosts({ + listingLimit: LISTING_LIMIT, + network, + page, + proposalType, + sortBy, + trackNo: trackId, + trackStatus: CustomStatus.Voting + }) + }; + + const responseArr = await Promise.allSettled(Object.values(fetches)); + + const results = responseArr.map((result) => { + if (result.status === 'fulfilled') { + return result.value; + } else { + return { + data: null, + error: result.reason + } as IApiResponse; + } + }); + const props: ILeaseAdminProps = { + network, + posts: {} + }; + Object.keys(fetches).forEach((key, index) => { + (props.posts as any)[key] = results[index]; + }); + + return { props }; +}; +interface ILeaseAdminProps { + posts: IReferendumV2PostsByStatus; + network: string; + error?: string; +} + +const LeaseAdmin: FC = (props) => { + const { posts, error } = props; + const { setNetwork } = useNetworkContext(); + + useEffect(() => { + setNetwork(props.network); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + if (error) return ; + + if (!posts || Object.keys(posts).length === 0) return null; + return <> + + + ; +}; + +export default LeaseAdmin; \ No newline at end of file diff --git a/pages/login/index.tsx b/pages/login/index.tsx new file mode 100644 index 0000000000..27c4164b88 --- /dev/null +++ b/pages/login/index.tsx @@ -0,0 +1,98 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { Col, Row, Skeleton } from 'antd'; +import { GetServerSideProps } from 'next'; +import dynamic from 'next/dynamic'; +import { useRouter } from 'next/router'; +import React, { useEffect, useState } from 'react'; +import Web2Login from 'src/components/Login/Web2Login'; +import { useNetworkContext, useUserDetailsContext } from 'src/context'; +import { Wallet } from 'src/types'; + +import { getNetworkFromReqHeaders } from '~src/api-utils'; +import SEOHead from '~src/global/SEOHead'; +import useHandleMetaMask from '~src/hooks/useHandleMetaMask'; + +const Web3Login = dynamic(() => import('src/components/Login/Web3Login'), { + loading: () => , + ssr: false +}); +const MetamaskLogin = dynamic(() => import('src/components/Login/MetamaskLogin'), { + loading: () => , + ssr: false +}); +const WalletConnectLogin = dynamic(() => import('src/components/Login/WalletConnectLogin'), { + loading: () => , + ssr: false +}); + +export const getServerSideProps: GetServerSideProps = async ({ req }) => { + const network = getNetworkFromReqHeaders(req.headers); + return { props: { network } }; +}; + +const Login = ({ network } : { network: string }) => { + const { setNetwork } = useNetworkContext(); + + useEffect(() => { + setNetwork(network); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const currentUser = useUserDetailsContext(); + const router = useRouter(); + const [displayWeb, setDisplayWeb] = useState(2); + const [chosenWallet, setChosenWallet] = useState(); + const [walletError, setWalletError] = useState(); + + const setDisplayWeb2 = () => setDisplayWeb(2); + + const onWalletSelect = (wallet: Wallet) => { + setChosenWallet(wallet); + + setDisplayWeb(3); + }; + + const setPolkadotWallet = () => { + onWalletSelect(Wallet.POLKADOT); + }; + + useEffect(() => { + if (currentUser?.id) { + router.push('/'); + } + }, [currentUser?.id, router]); + + return ( + <> + + + + {displayWeb === 2 ? ( + + ) : null} + + { + displayWeb === 3 && chosenWallet && <> + { + chosenWallet === Wallet.METAMASK ? + + : chosenWallet == Wallet.WALLETCONNECT ? + : + + } + + } + + + + ); +}; + +export default Login; \ No newline at end of file diff --git a/pages/medium-spender/index.tsx b/pages/medium-spender/index.tsx new file mode 100644 index 0000000000..86f435d7d1 --- /dev/null +++ b/pages/medium-spender/index.tsx @@ -0,0 +1,121 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import { GetServerSideProps } from 'next'; +import { getOnChainPosts, IPostsListingResponse } from 'pages/api/v1/listing/on-chain-posts'; +import { IReferendumV2PostsByStatus } from 'pages/root'; +import React, { FC, useEffect } from 'react'; + +import { getNetworkFromReqHeaders } from '~src/api-utils'; +import TrackListing from '~src/components/Listing/Tracks/TrackListing'; +import { CustomStatus } from '~src/components/Listing/Tracks/TrackListingCard'; +import { useNetworkContext } from '~src/context'; +import { LISTING_LIMIT } from '~src/global/listingLimit'; +import { networkTrackInfo } from '~src/global/post_trackInfo'; +import { ProposalType } from '~src/global/proposalType'; +import SEOHead from '~src/global/SEOHead'; +import { sortValues } from '~src/global/sortOptions'; +import { IApiResponse, PostOrigin } from '~src/types'; +import { ErrorState } from '~src/ui-components/UIStates'; + +export const getServerSideProps: GetServerSideProps = async ({ req, query }) => { + const { page = 1, sortBy = sortValues.NEWEST } = query; + const network = getNetworkFromReqHeaders(req.headers); + + if(!networkTrackInfo[network][PostOrigin.MEDIUM_SPENDER]) { + return { props: { error: `Invalid track for ${network}` } }; + } + + const { trackId } = networkTrackInfo[network][PostOrigin.MEDIUM_SPENDER]; + const proposalType = ProposalType.OPEN_GOV; + + const fetches = { + all: getOnChainPosts({ + listingLimit: LISTING_LIMIT, + network, + page, + proposalType, + sortBy, + trackNo: trackId, + trackStatus: 'All' + }), + closed: getOnChainPosts({ + listingLimit: LISTING_LIMIT, + network, + page, + proposalType, + sortBy, + trackNo: trackId, + trackStatus: CustomStatus.Closed + }), + submitted: getOnChainPosts({ + listingLimit: LISTING_LIMIT, + network, + page, + proposalType, + sortBy, + trackNo: trackId, + trackStatus: CustomStatus.Submitted + }), + voting: getOnChainPosts({ + listingLimit: LISTING_LIMIT, + network, + page, + proposalType, + sortBy, + trackNo: trackId, + trackStatus: CustomStatus.Voting + }) + }; + + const responseArr = await Promise.allSettled(Object.values(fetches)); + + const results = responseArr.map((result) => { + if (result.status === 'fulfilled') { + return result.value; + } else { + return { + data: null, + error: result.reason + } as IApiResponse; + } + }); + const props: IMediumSpenderProps = { + network, + posts: {} + }; + Object.keys(fetches).forEach((key, index) => { + (props.posts as any)[key] = results[index]; + }); + + return { props }; +}; +interface IMediumSpenderProps { + posts: IReferendumV2PostsByStatus; + network: string; + error?: string; +} + +const MediumSpender: FC = (props) => { + const { posts, error } = props; + const { setNetwork } = useNetworkContext(); + + useEffect(() => { + setNetwork(props.network); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + if (error) return ; + + if (!posts || Object.keys(posts).length === 0) return null; + return <> + + + ; +}; + +export default MediumSpender; \ No newline at end of file diff --git a/pages/member-referenda/[id].tsx b/pages/member-referenda/[id].tsx new file mode 100644 index 0000000000..bce685d5d1 --- /dev/null +++ b/pages/member-referenda/[id].tsx @@ -0,0 +1,68 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import { GetServerSideProps } from 'next'; +import { getOnChainPost, IPostResponse } from 'pages/api/v1/posts/on-chain-post'; +import React, { FC } from 'react'; +import Post from 'src/components/Post/Post'; +import BackToListingView from 'src/ui-components/BackToListingView'; +import { ErrorState, LoadingState } from 'src/ui-components/UIStates'; + +import { getNetworkFromReqHeaders } from '~src/api-utils'; +import { useNetworkContext } from '~src/context'; +import { noTitle } from '~src/global/noTitle'; +import { networkTrackInfo } from '~src/global/post_trackInfo'; +import { ProposalType } from '~src/global/proposalType'; +import SEOHead from '~src/global/SEOHead'; + +const proposalType = ProposalType.FELLOWSHIP_REFERENDUMS; +export const getServerSideProps:GetServerSideProps = async ({ req, query }) => { + const { id } = query; + + const network = getNetworkFromReqHeaders(req.headers); + const { data, error } = await getOnChainPost({ + network, + postId: id, + proposalType + }); + return { props: { data, error, network } }; +}; + +interface IReferendaPostProps { + data: IPostResponse; + error?: string; + network: string +} + +const ReferendaPost: FC = (props) => { + const { data: post, error } = props; + const { setNetwork } = useNetworkContext(); + setNetwork(props.network); + + if (error) return ; + + if (post) { + let trackName = ''; + for (const key of Object.keys(networkTrackInfo[props.network])) { + if(networkTrackInfo[props.network][key].trackId == post.track_number && ('fellowshipOrigin' in networkTrackInfo[props.network][key])) { + trackName = key; + } + } + + return <> + + + + +
+ +
+ ; + } + + return
; + +}; + +export default ReferendaPost; diff --git a/pages/member-referenda/index.tsx b/pages/member-referenda/index.tsx new file mode 100644 index 0000000000..c2c9e3596f --- /dev/null +++ b/pages/member-referenda/index.tsx @@ -0,0 +1,67 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import { GetServerSideProps } from 'next'; +import { getOnChainPosts, IPostsListingResponse } from 'pages/api/v1/listing/on-chain-posts'; +import React, { FC } from 'react'; + +import { getNetworkFromReqHeaders } from '~src/api-utils'; +import TrackListing from '~src/components/Listing/FellowshipReferendum/TrackListing'; +import { useNetworkContext } from '~src/context'; +import { LISTING_LIMIT } from '~src/global/listingLimit'; +import { networkTrackInfo } from '~src/global/post_trackInfo'; +import { ProposalType } from '~src/global/proposalType'; +import SEOHead from '~src/global/SEOHead'; +import { sortValues } from '~src/global/sortOptions'; +import { ErrorState, PostEmptyState } from '~src/ui-components/UIStates'; + +export const getServerSideProps: GetServerSideProps = async ({ req, query }) => { + const { page = 1, sortBy = sortValues.NEWEST } = query; + const network = getNetworkFromReqHeaders(req.headers); + + const proposalType = ProposalType.FELLOWSHIP_REFERENDUMS; + + const { data, error } = await getOnChainPosts({ + listingLimit: LISTING_LIMIT, + network, + page, + proposalType, + sortBy + }); + return { props: { data, error, network } }; +}; + +interface IFellowshipReferendumProps { + data?: IPostsListingResponse; + error?: string; + network: string; +} + +const FellowshipAdmin: FC = (props) => { + const { data, error, network } = props; + const { setNetwork } = useNetworkContext(); + setNetwork(network); + + if (error) return ; + if (!data) return ; + const { posts } = data; + + const fellowshipReferendumPostOrigins: string[] = []; + if (networkTrackInfo?.[network]) { + Object.entries(networkTrackInfo?.[network]).forEach(([key, value]) => { + if (value?.fellowshipOrigin) { + fellowshipReferendumPostOrigins.push(key); + } + }); + } + return <> + + + ; +}; + +export default FellowshipAdmin; \ No newline at end of file diff --git a/pages/members/index.tsx b/pages/members/index.tsx new file mode 100644 index 0000000000..d1af270d30 --- /dev/null +++ b/pages/members/index.tsx @@ -0,0 +1,47 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. +import { GetServerSideProps } from 'next'; +import React, { useEffect } from 'react'; + +import { getNetworkFromReqHeaders } from '~src/api-utils'; +import WhitelistMembersContainer from '~src/components/Listing/WhitelistMembers/WhitelistMembersContainer'; +import { useNetworkContext } from '~src/context'; +import SEOHead from '~src/global/SEOHead'; + +export enum EMembersType { + WHITELIST = 'whitelist', + FELLOWSHIP = 'fellowship', + COUNCIL = 'council' +} + +export const getServerSideProps: GetServerSideProps = async ({ req }) => { + const network = getNetworkFromReqHeaders(req.headers); + return { props: { network } }; +}; + +const WhitelistMembers = (props: { network: string }) => { + const { setNetwork } = useNetworkContext(); + + useEffect(() => { + setNetwork(props.network); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( + <> + +

Open Tech Committee Members

+ + {/* Intro and Create Post Button */} +
+

+ Open Tech Committee Members is a mostly self-governing expert body with a primary goal of representing the humans who embody and contain the technical knowledge base of the Polkadot network and protocol. +

+
+ + + ); +}; + +export default WhitelistMembers; \ No newline at end of file diff --git a/pages/motion/[id].tsx b/pages/motion/[id].tsx new file mode 100644 index 0000000000..b31bc99810 --- /dev/null +++ b/pages/motion/[id].tsx @@ -0,0 +1,63 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import { GetServerSideProps } from 'next'; +import { getOnChainPost, IPostResponse } from 'pages/api/v1/posts/on-chain-post'; +import React, { FC, useEffect } from 'react'; +import Post from 'src/components/Post/Post'; +import { PostCategory } from 'src/global/post_categories'; +import BackToListingView from 'src/ui-components/BackToListingView'; +import { ErrorState, LoadingState } from 'src/ui-components/UIStates'; + +import { getNetworkFromReqHeaders } from '~src/api-utils'; +import { useNetworkContext } from '~src/context'; +import { noTitle } from '~src/global/noTitle'; +import { ProposalType } from '~src/global/proposalType'; +import SEOHead from '~src/global/SEOHead'; + +const proposalType = ProposalType.COUNCIL_MOTIONS; +export const getServerSideProps:GetServerSideProps = async ({ req, query }) => { + const { id } = query; + + const network = getNetworkFromReqHeaders(req.headers); + const { data, error } = await getOnChainPost({ + network, + postId: id, + proposalType + }); + return { props: { data, error, network } }; +}; +interface IMotionPostProps { + data: IPostResponse; + error?: string; + network: string; +} + +const MotionPost: FC = (props) => { + const { data: post, error } = props; + const { setNetwork } = useNetworkContext(); + + useEffect(() => { + setNetwork(props.network); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + if (error) return ; + if (!post) return null; + + if (post) return (<> + + + + +
+ +
+ ); + + return
; + +}; + +export default MotionPost; diff --git a/pages/motions/index.tsx b/pages/motions/index.tsx new file mode 100644 index 0000000000..f1ac49acf8 --- /dev/null +++ b/pages/motions/index.tsx @@ -0,0 +1,102 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. +import { Pagination } from 'antd'; +import { GetServerSideProps } from 'next'; +import { useRouter } from 'next/router'; +import { getOnChainPosts, IPostsListingResponse } from 'pages/api/v1/listing/on-chain-posts'; +import React, { FC, useEffect } from 'react'; + +import { getNetworkFromReqHeaders } from '~src/api-utils'; +import Listing from '~src/components/Listing'; +import { useNetworkContext } from '~src/context'; +import { LISTING_LIMIT } from '~src/global/listingLimit'; +import { ProposalType } from '~src/global/proposalType'; +import SEOHead from '~src/global/SEOHead'; +import { sortValues } from '~src/global/sortOptions'; +import { ErrorState } from '~src/ui-components/UIStates'; +import { handlePaginationChange } from '~src/util/handlePaginationChange'; + +export const getServerSideProps: GetServerSideProps = async ({ req, query }) => { + const { page = 1, sortBy = sortValues.NEWEST } = query; + const proposalType = ProposalType.COUNCIL_MOTIONS; + const network = getNetworkFromReqHeaders(req.headers); + const { data, error } = await getOnChainPosts({ + listingLimit: LISTING_LIMIT, + network, + page, + proposalType, + sortBy + }); + return { props: { data, error, network } }; +}; + +interface IMotionsProps { + data?: IPostsListingResponse; + error?: string; + network: string; +} +const Motions: FC = (props) => { + const { data, error } = props; + const { setNetwork } = useNetworkContext(); + + useEffect(() => { + setNetwork(props.network); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const router = useRouter(); + + if (error) return ; + if (!data) return null; + const { posts, count } = data; + const onPaginationChange = (page:number) => { + router.push({ + query:{ + page + } + }); + handlePaginationChange({ limit: LISTING_LIMIT, page }); + }; + + return ( + <> + +

On Chain Motions

+ + {/* Intro and Create Post Button */} +
+

+ This is the place to discuss on-chain motions. On-chain posts are automatically generated as soon as they are created on the chain. + Only the proposer is able to edit them. +

+
+ +
+
+

{ count } Motions

+
+ +
+ +
+ { + !!count && count > 0 && count > LISTING_LIMIT && + + } +
+
+
+ + ); +}; + +export default Motions; \ No newline at end of file diff --git a/pages/news/index.tsx b/pages/news/index.tsx new file mode 100644 index 0000000000..88f522e8af --- /dev/null +++ b/pages/news/index.tsx @@ -0,0 +1,99 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import { GetServerSideProps } from 'next'; +import Image from 'next/image'; +import { getNetworkSocials } from 'pages/api/v1/network-socials'; +import React, { FC, useEffect } from 'react'; +import { TwitterTimelineEmbed } from 'react-twitter-embed'; + +import kusamaLogo from '~assets/kusama-logo.gif'; +import polkadotLogo from '~assets/parachain-logos/polkadot-logo.jpg'; +import { getNetworkFromReqHeaders } from '~src/api-utils'; +import { useNetworkContext } from '~src/context'; +import SEOHead from '~src/global/SEOHead'; +import { NetworkSocials } from '~src/types'; +import { ErrorState, PostEmptyState } from '~src/ui-components/UIStates'; + +export const getServerSideProps: GetServerSideProps = async ({ req }) => { + const network = getNetworkFromReqHeaders(req.headers); + const { data, error } = await getNetworkSocials({ network }); + return { props: { data, error, network } }; +}; + +interface Props { + network: string; + data: NetworkSocials; + error: string; +} + +enum Profile { + Polkadot='polkadot', + Kusama='kusamanetwork' +} + +const News: FC = ({ data, error, network }) => { + const { setNetwork } = useNetworkContext(); + + useEffect(() => { + setNetwork(network); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + if(error) return ; + + if(!data?.twitter) return ; + + const profile = data?.twitter.split('/')[3] || Profile.Polkadot; + const isPolkadotOrKusama = profile === Profile.Kusama || profile === Profile.Polkadot; + const profile2 = profile === Profile.Kusama? Profile.Polkadot: Profile.Kusama; + + const vh = Math.max(document.documentElement.clientHeight || 0, window.innerHeight || 0); + + return ( + <> + +
+

+ News +

+
+
+ {isPolkadotOrKusama &&
+ {`${profile +

{profile === Profile.Kusama? 'Kusama': 'Polkadot'}

+
} + +
+ {isPolkadotOrKusama && (
+ {isPolkadotOrKusama &&
+ {`${profile2 +

{profile2 === Profile.Kusama? 'Kusama': 'Polkadot'}

+
} + +
)} +
+
+ + ); + +}; + +export default News; \ No newline at end of file diff --git a/pages/notification-settings/index.tsx b/pages/notification-settings/index.tsx new file mode 100644 index 0000000000..4d2f500590 --- /dev/null +++ b/pages/notification-settings/index.tsx @@ -0,0 +1,33 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. +import { GetServerSideProps } from 'next'; +import React, { useEffect } from 'react'; + +import { getNetworkFromReqHeaders } from '~src/api-utils'; +import NotificationSettings from '~src/components/NotificationSettings'; +import { useNetworkContext } from '~src/context'; +import SEOHead from '~src/global/SEOHead'; + +export const getServerSideProps: GetServerSideProps = async ({ req }) => { + const network = getNetworkFromReqHeaders(req.headers); + return { props: { network } }; +}; + +const NotificationSettingsPage = ({ network }: { network:string }) => { + const { setNetwork } = useNetworkContext(); + + useEffect(() => { + setNetwork(network); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( + <> + + + + ); +}; + +export default NotificationSettingsPage; diff --git a/pages/parachains/index.tsx b/pages/parachains/index.tsx new file mode 100644 index 0000000000..c3fe5c7686 --- /dev/null +++ b/pages/parachains/index.tsx @@ -0,0 +1,143 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import { Col,Row,Tabs } from 'antd'; +import { GetServerSideProps } from 'next'; +import React, { useEffect, useState } from 'react'; +import styled from 'styled-components'; + +import { getNetworkFromReqHeaders } from '~src/api-utils'; +import ParachainInfoCard from '~src/components/Parachains/ParachainInfoCard'; +import { useNetworkContext } from '~src/context'; +import SEOHead from '~src/global/SEOHead'; +import CountBadgePill from '~src/ui-components/CountBadgePill'; + +import ChainDataTable from '../../src/components/Parachains/ChainDataTable'; + +interface Props { + className?: string + network: string +} + +export const getServerSideProps: GetServerSideProps = async ({ req }) => { + const network = getNetworkFromReqHeaders(req.headers); + return { props: { network } }; +}; + +const Parachains = ({ className, network }: Props) => { + const { setNetwork } = useNetworkContext(); + + useEffect(() => { + setNetwork(network); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const [parachainsData, setParachainsData] = useState([]); + + useEffect(() => { + fetch('/parachains.json') + .then((r) => r.json()) + .then((data) => { + setParachainsData(data); + }); + },[]); + + const polkadotProjects = parachainsData?.filter((item : any) => item?.chain === 'polkadot').length; + const kusamaProjects = parachainsData?.filter((item : any) => item?.chain === 'kusama').length; + + const tabItems = [ + // eslint-disable-next-line sort-keys + { label: , key: 'polkadot', children: }, + // eslint-disable-next-line sort-keys + { label: , key: 'kusama', children: } + ]; + + return ( + <> + +
+

Polkadot and Kusama ecosystem and directory

+ + + + + + + + + + +
+

Projects

+ +
+
+ + ); +}; + +export default styled(Parachains)` + + .loader-cont { + display: flex; + justify-content: center; + margin-top: 30%; + } + + .ma-sm-1 { + @media only screen and (max-width: 768px) { + margin: 1rem; + } + } + + .card-group { + margin-top: 32px; + flex-wrap: nowrap; + max-width: 99.9%; + overflow-x: hidden !important; + + &:hover { + overflow-x: auto !important; + } + + @media only screen and (max-width: 768px){ + overflow-x: hidden !important; + padding-left: 1em; + flex-direction: column; + align-items: center; + max-width: 100%; + margin-left: 4px; + margin-top: 22px; + + + &:hover { + overflow-x: hidden !important; + } + } + } + + .ant-tabs-tab-bg-white .ant-tabs-tab:not(.ant-tabs-tab-active) { + background-color: white; + border-top-color: white; + border-left-color: white; + border-right-color: white; + border-bottom-color: #E1E6EB; + } + + .ant-tabs-tab-bg-white .ant-tabs-tab-active{ + border-top-color: #E1E6EB; + border-left-color: #E1E6EB; + border-right-color: #E1E6EB; + border-radius: 6px 6px 0 0 !important; + } + + .ant-tabs-tab-bg-white .ant-tabs-nav:before{ + border-bottom: 1px solid #E1E6EB; + } +`; \ No newline at end of file diff --git a/pages/post/[id].tsx b/pages/post/[id].tsx new file mode 100644 index 0000000000..ce2977c444 --- /dev/null +++ b/pages/post/[id].tsx @@ -0,0 +1,62 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import type { GetServerSideProps } from 'next'; +import { getOffChainPost } from 'pages/api/v1/posts/off-chain-post'; +import { IPostResponse } from 'pages/api/v1/posts/on-chain-post'; +import React, { FC, useEffect } from 'react'; +import { PostCategory } from 'src/global/post_categories'; +import BackToListingView from 'src/ui-components/BackToListingView'; +import { ErrorState, LoadingState } from 'src/ui-components/UIStates'; + +import { getNetworkFromReqHeaders } from '~src/api-utils'; +import Post from '~src/components/Post/Post'; +import { useNetworkContext } from '~src/context'; +import { noTitle } from '~src/global/noTitle'; +import { OffChainProposalType, ProposalType } from '~src/global/proposalType'; +import SEOHead from '~src/global/SEOHead'; + +export const getServerSideProps: GetServerSideProps = async ({ req, query }) => { + const { id } = query; + + const network = getNetworkFromReqHeaders(req.headers); + const { data, error } = await getOffChainPost({ + network, + postId: id, + proposalType: OffChainProposalType.DISCUSSIONS + }); + return { props: { data, error, network } }; +}; + +interface IDiscussionPostProps { + data: IPostResponse; + error?: string; + network: string; +} +const DiscussionPost: FC = (props) => { + const { data: post, error } = props; + const { setNetwork } = useNetworkContext(); + + useEffect(() => { + setNetwork(props.network); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + if (error) return ; + + if (post) return (<> + + + + +
+ +
+ ); + + return
; + +}; + +export default DiscussionPost; \ No newline at end of file diff --git a/pages/post/create.tsx b/pages/post/create.tsx new file mode 100644 index 0000000000..187cb61396 --- /dev/null +++ b/pages/post/create.tsx @@ -0,0 +1,44 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. +import { Skeleton } from 'antd'; +import { GetServerSideProps } from 'next'; +import dynamic from 'next/dynamic'; +import React, { useEffect } from 'react'; + +import { getNetworkFromReqHeaders } from '~src/api-utils'; +import { useNetworkContext } from '~src/context'; +import { ProposalType } from '~src/global/proposalType'; +import SEOHead from '~src/global/SEOHead'; + +const CreatePost = dynamic(() => import('~src/components/Post/CreatePost'), { + loading: () =>
+ + + + + +
, + ssr: false +}); + +export const getServerSideProps: GetServerSideProps = async ({ req }) => { + const network = getNetworkFromReqHeaders(req.headers); + return { props: { network } }; +}; + +const Create = ({ network }: { network: string }) => { + const { setNetwork } = useNetworkContext(); + + useEffect(() => { + setNetwork(network); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return <> + + + ; +}; + +export default Create; \ No newline at end of file diff --git a/pages/preimages/index.tsx b/pages/preimages/index.tsx new file mode 100644 index 0000000000..f281f946d1 --- /dev/null +++ b/pages/preimages/index.tsx @@ -0,0 +1,99 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import { Pagination, Skeleton } from 'antd'; +import { GetServerSideProps } from 'next'; +import dynamic from 'next/dynamic'; +import { useRouter } from 'next/router'; +import { getPreimages, IPreimagesListingResponse } from 'pages/api/v1/listing/preimages'; +import React, { FC, useEffect } from 'react'; + +import { getNetworkFromReqHeaders } from '~src/api-utils'; +import { useNetworkContext } from '~src/context'; +import { LISTING_LIMIT } from '~src/global/listingLimit'; +import SEOHead from '~src/global/SEOHead'; +import { ErrorState } from '~src/ui-components/UIStates'; +import { handlePaginationChange } from '~src/util/handlePaginationChange'; + +const PreImagesTable = dynamic(() => import('~src/components/PreImagesTable'), { + loading: () => , + ssr: false +}); + +export const getServerSideProps: GetServerSideProps = async ({ req, query }) => { + const { page = 1 } = query; + const network = getNetworkFromReqHeaders(req.headers); + + const { data, error } = await getPreimages({ + listingLimit: LISTING_LIMIT, + network, + page + }); + return { props: { data, error, network } }; +}; + +interface IPreImagesProps { + data?: IPreimagesListingResponse; + error?: string; + network: string +} + +const PreImages: FC = (props) => { + const { data, error } = props; + const { setNetwork } = useNetworkContext(); + + useEffect(() => { + setNetwork(props.network); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const router = useRouter(); + + if (error) return ; + if (!data) return null; + const { preimages, count } = data; + + const onPaginationChange = (page: number) => { + router.push({ + query: { + page + } + }); + handlePaginationChange({ limit: LISTING_LIMIT, page }); + }; + + return ( + <> + +

{ count } Preimages

+ + {/*
+ +
*/} + +
+
+ + +
+ { + !!preimages && preimages.length > 0 && count && count > 0 && count > LISTING_LIMIT && + + } +
+
+
+ + ); +}; + +export default PreImages; \ No newline at end of file diff --git a/pages/privacy.tsx b/pages/privacy.tsx new file mode 100644 index 0000000000..84d48762ee --- /dev/null +++ b/pages/privacy.tsx @@ -0,0 +1,41 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. +import { GetServerSideProps } from 'next'; +import React, { FC, useEffect } from 'react'; + +import { getNetworkFromReqHeaders } from '~src/api-utils'; +import { PrivacyPolicy } from '~src/components/LegalDocuments'; +import { useNetworkContext } from '~src/context'; +import SEOHead from '~src/global/SEOHead'; + +interface IPrivacyPage { + network: string; +} + +export const getServerSideProps: GetServerSideProps = async ({ req }) => { + const network = getNetworkFromReqHeaders(req.headers); + return { + props: { + network + } + }; +}; +const PrivacyPage: FC = (props) => { + const { network } = props; + const { setNetwork } = useNetworkContext(); + + useEffect(() => { + setNetwork(network); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( + <> + + + + ); +}; + +export default PrivacyPage; \ No newline at end of file diff --git a/pages/profile/[address].tsx b/pages/profile/[address].tsx new file mode 100644 index 0000000000..6c84b29a24 --- /dev/null +++ b/pages/profile/[address].tsx @@ -0,0 +1,70 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import { Skeleton } from 'antd'; +import { GetServerSideProps } from 'next'; +import dynamic from 'next/dynamic'; +import { getProfileWithAddress } from 'pages/api/v1/auth/data/profileWithAddress'; +import React, { FC, useEffect } from 'react'; + +import { getNetworkFromReqHeaders } from '~src/api-utils'; +import { ProfileDetails } from '~src/auth/types'; +import { useNetworkContext } from '~src/context'; +import SEOHead from '~src/global/SEOHead'; + +interface IProfileProps { + className?: string; + userProfile: { + data: ProfileDetails; + error: string | null; + }; + network: string; +} + +export const getServerSideProps: GetServerSideProps = async (context) => { + const address = context.params?.address; + + const network = getNetworkFromReqHeaders(context.req.headers); + + const { data, error } = await getProfileWithAddress({ + address + }); + const props: IProfileProps = { + network, + userProfile: { + data: data?.profile || { + badges: [], + bio: '', + image: '', + social_links: [], + title: '' + }, + error: error + } + }; + return { props: props }; +}; + +const ProfileComponent = dynamic(() => import('~src/components/Profile'),{ + loading: () => , + ssr: false +}); + +const Profile: FC = (props) => { + const { className, userProfile, network } = props; + const { setNetwork } = useNetworkContext(); + useEffect(() => { + setNetwork(network); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( + <> + + + + ); +}; + +export default Profile; \ No newline at end of file diff --git a/pages/proposal/[id].tsx b/pages/proposal/[id].tsx new file mode 100644 index 0000000000..549988db01 --- /dev/null +++ b/pages/proposal/[id].tsx @@ -0,0 +1,71 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import { Skeleton } from 'antd'; +import { GetServerSideProps } from 'next'; +import dynamic from 'next/dynamic'; +import { getOnChainPost, IPostResponse } from 'pages/api/v1/posts/on-chain-post'; +import React, { FC, useEffect } from 'react'; +import { PostCategory } from 'src/global/post_categories'; +import BackToListingView from 'src/ui-components/BackToListingView'; +import { ErrorState, LoadingState } from 'src/ui-components/UIStates'; + +import { getNetworkFromReqHeaders } from '~src/api-utils'; +import { useNetworkContext } from '~src/context'; +import { noTitle } from '~src/global/noTitle'; +import { ProposalType } from '~src/global/proposalType'; +import SEOHead from '~src/global/SEOHead'; + +const Post = dynamic(() => import('src/components/Post/Post'), { + loading: () => , + ssr: false +}); + +const proposalType = ProposalType.DEMOCRACY_PROPOSALS; +export const getServerSideProps:GetServerSideProps = async ({ req, query }) => { + const { id } = query; + + const network = getNetworkFromReqHeaders(req.headers); + const { data, error } = await getOnChainPost({ + network, + postId: id, + proposalType + }); + return { props: { data, error, network } }; +}; + +interface IProposalPostProps { + data: IPostResponse; + error?: string; + network: string; +} + +const ProposalPost: FC = (props) => { + const { data: post, error } = props; + + const { setNetwork } = useNetworkContext(); + + useEffect(() => { + setNetwork(props.network); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + if (error) return ; + if (!post) return null; + + if (post) return (<> + + + + +
+ +
+ ); + + return
; + +}; + +export default ProposalPost; diff --git a/pages/proposals/index.tsx b/pages/proposals/index.tsx new file mode 100644 index 0000000000..add171794a --- /dev/null +++ b/pages/proposals/index.tsx @@ -0,0 +1,104 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. +import { Pagination } from 'antd'; +import { GetServerSideProps } from 'next'; +import { useRouter } from 'next/router'; +import { getOnChainPosts, IPostsListingResponse } from 'pages/api/v1/listing/on-chain-posts'; +import React, { FC, useEffect } from 'react'; + +import { getNetworkFromReqHeaders } from '~src/api-utils'; +import Listing from '~src/components/Listing'; +import { useNetworkContext } from '~src/context'; +import { LISTING_LIMIT } from '~src/global/listingLimit'; +import { ProposalType } from '~src/global/proposalType'; +import SEOHead from '~src/global/SEOHead'; +import { sortValues } from '~src/global/sortOptions'; +import { ErrorState } from '~src/ui-components/UIStates'; +import { handlePaginationChange } from '~src/util/handlePaginationChange'; + +export const getServerSideProps: GetServerSideProps = async ({ req, query }) => { + const { page = 1, sortBy = sortValues.NEWEST } = query; + const proposalType = ProposalType.DEMOCRACY_PROPOSALS; + const network = getNetworkFromReqHeaders(req.headers); + const { data, error } = await getOnChainPosts({ + listingLimit: LISTING_LIMIT, + network, + page, + proposalType, + sortBy + }); + return { props: { data, error, network } }; +}; + +interface IProposalsProps { + data?: IPostsListingResponse; + error?: string; + network: string; +} + +const Proposals: FC = (props) => { + const { data, error } = props; + const { setNetwork } = useNetworkContext(); + + useEffect(() => { + setNetwork(props.network); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const router = useRouter(); + + if (error) return ; + if (!data) return null; + const { posts, count } = data; + + const onPaginationChange = (page:number) => { + router.push({ + query:{ + page + } + }); + handlePaginationChange({ limit: LISTING_LIMIT, page }); + }; + + return ( + <> + +

On Chain Proposals

+ + {/* Intro and Create Post Button */} +
+

+ This is the place to discuss on-chain proposals. On-chain posts are automatically generated as soon as they are created on the chain. + Only the proposer is able to edit them. +

+
+ +
+
+

{ count } Proposals

+
+ +
+ +
+ { + !!posts && posts.length > 0 && !!count && count > 0 && count > LISTING_LIMIT && + + } +
+
+
+ + ); +}; + +export default Proposals; \ No newline at end of file diff --git a/pages/referenda/[id].tsx b/pages/referenda/[id].tsx new file mode 100644 index 0000000000..29e21d904e --- /dev/null +++ b/pages/referenda/[id].tsx @@ -0,0 +1,72 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import { GetServerSideProps } from 'next'; +import { getOnChainPost, IPostResponse } from 'pages/api/v1/posts/on-chain-post'; +import React, { FC, useEffect } from 'react'; +import Post from 'src/components/Post/Post'; +import BackToListingView from 'src/ui-components/BackToListingView'; +import { ErrorState, LoadingState } from 'src/ui-components/UIStates'; + +import { getNetworkFromReqHeaders } from '~src/api-utils'; +import { useNetworkContext } from '~src/context'; +import { noTitle } from '~src/global/noTitle'; +import { networkTrackInfo } from '~src/global/post_trackInfo'; +import { ProposalType } from '~src/global/proposalType'; +import SEOHead from '~src/global/SEOHead'; + +const proposalType = ProposalType.OPEN_GOV; +export const getServerSideProps:GetServerSideProps = async ({ req, query }) => { + const { id } = query; + + const network = getNetworkFromReqHeaders(req.headers); + const { data, error } = await getOnChainPost({ + network, + postId: id, + proposalType + }); + return { props: { data, error, network } }; +}; + +interface IReferendaPostProps { + data: IPostResponse; + error?: string; + network: string +} + +const ReferendaPost: FC = (props) => { + const { data: post, error } = props; + const { setNetwork } = useNetworkContext(); + + useEffect(() => { + setNetwork(props.network); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + if (error) return ; + + if (post) { + let trackName = ''; + for (const key of Object.keys(networkTrackInfo[props.network])) { + if(networkTrackInfo[props.network][key].trackId == post.track_number && !('fellowshipOrigin' in networkTrackInfo[props.network][key])) { + trackName = key; + } + } + + return <> + + + {trackName && } + +
+ +
+ ; + } + + return
; + +}; + +export default ReferendaPost; diff --git a/pages/referenda/index.tsx b/pages/referenda/index.tsx new file mode 100644 index 0000000000..95f9d6febf --- /dev/null +++ b/pages/referenda/index.tsx @@ -0,0 +1,104 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. +import { Pagination } from 'antd'; +import { GetServerSideProps } from 'next'; +import { useRouter } from 'next/router'; +import { getOnChainPosts, IPostsListingResponse } from 'pages/api/v1/listing/on-chain-posts'; +import React, { FC, useEffect } from 'react'; + +import { getNetworkFromReqHeaders } from '~src/api-utils'; +import Listing from '~src/components/Listing'; +import { useNetworkContext } from '~src/context'; +import { LISTING_LIMIT } from '~src/global/listingLimit'; +import { ProposalType } from '~src/global/proposalType'; +import SEOHead from '~src/global/SEOHead'; +import { sortValues } from '~src/global/sortOptions'; +import { ErrorState } from '~src/ui-components/UIStates'; +import { handlePaginationChange } from '~src/util/handlePaginationChange'; + +export const getServerSideProps: GetServerSideProps = async ({ req, query }) => { + const { page = 1, sortBy = sortValues.NEWEST } = query; + const proposalType = ProposalType.REFERENDUMS; + const network = getNetworkFromReqHeaders(req.headers); + const { data, error } = await getOnChainPosts({ + listingLimit: LISTING_LIMIT, + network, + page, + proposalType, + sortBy + }); + return { props: { data, error, network } }; +}; + +interface IReferendaProps { + data?: IPostsListingResponse; + error?: string; + network: string; +} + +const Referenda: FC = (props) => { + const { data, error } = props; + const { setNetwork } = useNetworkContext(); + + useEffect(() => { + setNetwork(props.network); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const router = useRouter(); + + if (error) return ; + if (!data) return null; + const { posts, count } = data; + + const onPaginationChange = (page:number) => { + router.push({ + query:{ + page + } + }); + handlePaginationChange({ limit: LISTING_LIMIT, page }); + }; + + return ( + <> + +

On Chain Referenda

+ + {/* Intro and Create Post Button */} +
+

+ This is the place to discuss on-chain referenda. On-chain posts are automatically generated as soon as they are created on the chain. + Only the proposer is able to edit them. +

+
+ +
+
+

{ count } Referenda

+
+ +
+ +
+ { + !!count && count > 0 && count > LISTING_LIMIT && + + } +
+
+
+ + ); +}; + +export default Referenda; \ No newline at end of file diff --git a/pages/referendum-canceller/index.tsx b/pages/referendum-canceller/index.tsx new file mode 100644 index 0000000000..7149e5d637 --- /dev/null +++ b/pages/referendum-canceller/index.tsx @@ -0,0 +1,121 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import { GetServerSideProps } from 'next'; +import { getOnChainPosts, IPostsListingResponse } from 'pages/api/v1/listing/on-chain-posts'; +import { IReferendumV2PostsByStatus } from 'pages/root'; +import React, { FC, useEffect } from 'react'; + +import { getNetworkFromReqHeaders } from '~src/api-utils'; +import TrackListing from '~src/components/Listing/Tracks/TrackListing'; +import { CustomStatus } from '~src/components/Listing/Tracks/TrackListingCard'; +import { useNetworkContext } from '~src/context'; +import { LISTING_LIMIT } from '~src/global/listingLimit'; +import { networkTrackInfo } from '~src/global/post_trackInfo'; +import { ProposalType } from '~src/global/proposalType'; +import SEOHead from '~src/global/SEOHead'; +import { sortValues } from '~src/global/sortOptions'; +import { IApiResponse, PostOrigin } from '~src/types'; +import { ErrorState } from '~src/ui-components/UIStates'; + +export const getServerSideProps: GetServerSideProps = async ({ req, query }) => { + const { page = 1, sortBy = sortValues.NEWEST } = query; + const network = getNetworkFromReqHeaders(req.headers); + + if(!networkTrackInfo[network][PostOrigin.REFERENDUM_CANCELLER]) { + return { props: { error: `Invalid track for ${network}` } }; + } + + const { trackId } = networkTrackInfo[network][PostOrigin.REFERENDUM_CANCELLER]; + const proposalType = ProposalType.OPEN_GOV; + + const fetches = { + all: getOnChainPosts({ + listingLimit: LISTING_LIMIT, + network, + page, + proposalType, + sortBy, + trackNo: trackId, + trackStatus: 'All' + }), + closed: getOnChainPosts({ + listingLimit: LISTING_LIMIT, + network, + page, + proposalType, + sortBy, + trackNo: trackId, + trackStatus: CustomStatus.Closed + }), + submitted: getOnChainPosts({ + listingLimit: LISTING_LIMIT, + network, + page, + proposalType, + sortBy, + trackNo: trackId, + trackStatus: CustomStatus.Submitted + }), + voting: getOnChainPosts({ + listingLimit: LISTING_LIMIT, + network, + page, + proposalType, + sortBy, + trackNo: trackId, + trackStatus: CustomStatus.Voting + }) + }; + + const responseArr = await Promise.allSettled(Object.values(fetches)); + + const results = responseArr.map((result) => { + if (result.status === 'fulfilled') { + return result.value; + } else { + return { + data: null, + error: result.reason + } as IApiResponse; + } + }); + const props: IReferendumCancellerProps = { + network, + posts: {} + }; + Object.keys(fetches).forEach((key, index) => { + (props.posts as any)[key] = results[index]; + }); + + return { props }; +}; +interface IReferendumCancellerProps { + posts: IReferendumV2PostsByStatus; + network: string; + error?: string; +} + +const ReferendumCanceller: FC = (props) => { + const { posts, error } = props; + const { setNetwork } = useNetworkContext(); + + useEffect(() => { + setNetwork(props.network); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + if (error) return ; + + if (!posts || Object.keys(posts).length === 0) return null; + return <> + + + ; +}; + +export default ReferendumCanceller; \ No newline at end of file diff --git a/pages/referendum-killer/index.tsx b/pages/referendum-killer/index.tsx new file mode 100644 index 0000000000..40948b2157 --- /dev/null +++ b/pages/referendum-killer/index.tsx @@ -0,0 +1,121 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import { GetServerSideProps } from 'next'; +import { getOnChainPosts, IPostsListingResponse } from 'pages/api/v1/listing/on-chain-posts'; +import { IReferendumV2PostsByStatus } from 'pages/root'; +import React, { FC, useEffect } from 'react'; + +import { getNetworkFromReqHeaders } from '~src/api-utils'; +import TrackListing from '~src/components/Listing/Tracks/TrackListing'; +import { CustomStatus } from '~src/components/Listing/Tracks/TrackListingCard'; +import { useNetworkContext } from '~src/context'; +import { LISTING_LIMIT } from '~src/global/listingLimit'; +import { networkTrackInfo } from '~src/global/post_trackInfo'; +import { ProposalType } from '~src/global/proposalType'; +import SEOHead from '~src/global/SEOHead'; +import { sortValues } from '~src/global/sortOptions'; +import { IApiResponse, PostOrigin } from '~src/types'; +import { ErrorState } from '~src/ui-components/UIStates'; + +export const getServerSideProps: GetServerSideProps = async ({ req, query }) => { + const { page = 1, sortBy = sortValues.NEWEST } = query; + const network = getNetworkFromReqHeaders(req.headers); + + if(!networkTrackInfo[network][PostOrigin.REFERENDUM_KILLER]) { + return { props: { error: `Invalid track for ${network}` } }; + } + + const { trackId } = networkTrackInfo[network][PostOrigin.REFERENDUM_KILLER]; + const proposalType = ProposalType.OPEN_GOV; + + const fetches = { + all: getOnChainPosts({ + listingLimit: LISTING_LIMIT, + network, + page, + proposalType, + sortBy, + trackNo: trackId, + trackStatus: 'All' + }), + closed: getOnChainPosts({ + listingLimit: LISTING_LIMIT, + network, + page, + proposalType, + sortBy, + trackNo: trackId, + trackStatus: CustomStatus.Closed + }), + submitted: getOnChainPosts({ + listingLimit: LISTING_LIMIT, + network, + page, + proposalType, + sortBy, + trackNo: trackId, + trackStatus: CustomStatus.Submitted + }), + voting: getOnChainPosts({ + listingLimit: LISTING_LIMIT, + network, + page, + proposalType, + sortBy, + trackNo: trackId, + trackStatus: CustomStatus.Voting + }) + }; + + const responseArr = await Promise.allSettled(Object.values(fetches)); + + const results = responseArr.map((result) => { + if (result.status === 'fulfilled') { + return result.value; + } else { + return { + data: null, + error: result.reason + } as IApiResponse; + } + }); + const props: IReferendumKillerProps = { + network, + posts: {} + }; + Object.keys(fetches).forEach((key, index) => { + (props.posts as any)[key] = results[index]; + }); + + return { props }; +}; +interface IReferendumKillerProps { + posts: IReferendumV2PostsByStatus; + network: string; + error?: string; +} + +const ReferendumKiller: FC = (props) => { + const { posts, error } = props; + const { setNetwork } = useNetworkContext(); + + useEffect(() => { + setNetwork(props.network); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + if (error) return ; + + if (!posts || Object.keys(posts).length === 0) return null; + return <> + + + ; +}; + +export default ReferendumKiller; \ No newline at end of file diff --git a/pages/referendum/[id].tsx b/pages/referendum/[id].tsx new file mode 100644 index 0000000000..e874d81c6f --- /dev/null +++ b/pages/referendum/[id].tsx @@ -0,0 +1,64 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import { GetServerSideProps } from 'next'; +import { getOnChainPost, IPostResponse } from 'pages/api/v1/posts/on-chain-post'; +import React, { FC, useEffect } from 'react'; +import Post from 'src/components/Post/Post'; +import { PostCategory } from 'src/global/post_categories'; +import BackToListingView from 'src/ui-components/BackToListingView'; +import { ErrorState, LoadingState } from 'src/ui-components/UIStates'; + +import { getNetworkFromReqHeaders } from '~src/api-utils'; +import { useNetworkContext } from '~src/context'; +import { noTitle } from '~src/global/noTitle'; +import { ProposalType } from '~src/global/proposalType'; +import SEOHead from '~src/global/SEOHead'; + +const proposalType = ProposalType.REFERENDUMS; +export const getServerSideProps:GetServerSideProps = async ({ req, query }) => { + const { id } = query; + + const network = getNetworkFromReqHeaders(req.headers); + const { data, error } = await getOnChainPost({ + network, + postId: id, + proposalType + }); + return { props: { data, error, network } }; +}; + +interface IReferendumPostProps { + data: IPostResponse; + error?: string; + network: string; +} + +const ReferendumPost: FC = (props) => { + const { data: post, error } = props; + const { setNetwork } = useNetworkContext(); + + useEffect(() => { + setNetwork(props.network); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + if (error) return ; + if (!post) return null; + + if (post) return (<> + + + + +
+ +
+ ); + + return
; + +}; + +export default ReferendumPost; diff --git a/pages/request-reset-password/index.tsx b/pages/request-reset-password/index.tsx new file mode 100644 index 0000000000..f35483c911 --- /dev/null +++ b/pages/request-reset-password/index.tsx @@ -0,0 +1,114 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import { Button,Form ,Input, Row } from 'antd'; +import { GetServerSideProps } from 'next'; +import { useRouter } from 'next/router'; +import React, { FC, useEffect, useState } from 'react'; +import AuthForm from 'src/ui-components/AuthForm'; +import messages from 'src/util/messages'; +import * as validation from 'src/util/validation'; + +import { getNetworkFromReqHeaders } from '~src/api-utils'; +import { MessageType } from '~src/auth/types'; +import { useNetworkContext } from '~src/context'; +import SEOHead from '~src/global/SEOHead'; +import { NotificationStatus } from '~src/types'; +import FilteredError from '~src/ui-components/FilteredError'; +import queueNotification from '~src/ui-components/QueueNotification'; +import nextApiClientFetch from '~src/util/nextApiClientFetch'; + +interface Props { + network: string +} + +export const getServerSideProps: GetServerSideProps = async ({ req }) => { + const network = getNetworkFromReqHeaders(req.headers); + return { props: { network } }; +}; + +const RequestResetPassword: FC = (props) => { + const { setNetwork } = useNetworkContext(); + + useEffect(() => { + setNetwork(props.network); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const router = useRouter(); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + + const handleSubmitForm = async (data: any) => { + setLoading(true); + const { email } = data; + if (email) { + const { data , error } = await nextApiClientFetch( 'api/v1/auth/actions/requestResetPassword', { email }); + if(error) { + console.log('error requesting reset passoword : ', error); + setError(error); + setLoading(false); + } + if(data) { + queueNotification({ + header: 'Success!', + message: data.message, + status: NotificationStatus.SUCCESS + }); + setLoading(false); + router.push('/login'); + } + } + }; + + return ( + <> + + +
+

Request Password Reset

+ +
+ + + + +
+ +
+ +
+ {error && } +
+
+
+ + ); +}; + +export default RequestResetPassword; diff --git a/pages/reset-password/index.tsx b/pages/reset-password/index.tsx new file mode 100644 index 0000000000..f13a5be243 --- /dev/null +++ b/pages/reset-password/index.tsx @@ -0,0 +1,139 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. +import { WarningOutlined } from '@ant-design/icons'; +import { Button, Form, Input, Row } from 'antd'; +import { GetServerSideProps } from 'next'; +import { useRouter } from 'next/router'; +import React, { useEffect, useState } from 'react'; +import AuthForm from 'src/ui-components/AuthForm'; +import messages from 'src/util/messages'; +import * as validation from 'src/util/validation'; + +import { getNetworkFromReqHeaders } from '~src/api-utils'; +import { MessageType } from '~src/auth/types'; +import { useNetworkContext } from '~src/context'; +import SEOHead from '~src/global/SEOHead'; +import { NotificationStatus } from '~src/types'; +import FilteredError from '~src/ui-components/FilteredError'; +import queueNotification from '~src/ui-components/QueueNotification'; +import nextApiClientFetch from '~src/util/nextApiClientFetch'; + +interface Props { + token: string; + userId: string; + network: string; +} + +export const getServerSideProps:GetServerSideProps = async (context) => { + const props: Props = { + network: getNetworkFromReqHeaders(context.req.headers), + token: `${context.query.token}`, + userId: `${context.query.userId}` + }; + + return { props }; +}; + +const ResetPassword = ({ network, token, userId } : Props): JSX.Element => { + const { setNetwork } = useNetworkContext(); + + useEffect(() => { + setNetwork(network); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const router = useRouter(); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + + const [newPassword, setNewPassword ] = useState(''); + + const handleSubmitForm = async (value: any) => { + setLoading(true); + if (value.password){ + const { data , error } = await nextApiClientFetch( 'api/v1/auth/actions/resetPassword', { newPassword, userId }); + if(error) { + console.log('error resetting passoword : ', error); + setError(error); + setLoading(false); + } + + if (data) { + queueNotification({ + header: 'Success!', + message: data.message, + status: NotificationStatus.SUCCESS + }); + setLoading(false); + router.push('/login'); + } + } + }; + + return ( + <> + + + {
+ { + token && userId ? <> +

Set new password

+ +
+ + + setNewPassword(e.target.value)} + placeholder="eg. password123" + className="rounded-md py-3 px-4" + id="password" + /> + +
+
+ +
+ {error && } +
+ :

+ + Password reset token and/or userId missing +

+ } +
} +
+ + ); +}; + +export default ResetPassword; \ No newline at end of file diff --git a/pages/root/index.tsx b/pages/root/index.tsx new file mode 100644 index 0000000000..da0dd0129b --- /dev/null +++ b/pages/root/index.tsx @@ -0,0 +1,127 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import { GetServerSideProps } from 'next'; +import { getOnChainPosts, IPostsListingResponse } from 'pages/api/v1/listing/on-chain-posts'; +import React, { FC, useEffect } from 'react'; + +import { getNetworkFromReqHeaders } from '~src/api-utils'; +import TrackListing from '~src/components/Listing/Tracks/TrackListing'; +import { CustomStatus } from '~src/components/Listing/Tracks/TrackListingCard'; +import { useNetworkContext } from '~src/context'; +import { LISTING_LIMIT } from '~src/global/listingLimit'; +import { networkTrackInfo } from '~src/global/post_trackInfo'; +import { ProposalType } from '~src/global/proposalType'; +import SEOHead from '~src/global/SEOHead'; +import { sortValues } from '~src/global/sortOptions'; +import { IApiResponse, PostOrigin } from '~src/types'; +import { ErrorState } from '~src/ui-components/UIStates'; + +export const getServerSideProps: GetServerSideProps = async ({ req, query }) => { + const { page = 1, sortBy = sortValues.NEWEST } = query; + const network = getNetworkFromReqHeaders(req.headers); + + if(!networkTrackInfo[network][PostOrigin.ROOT]) { + return { props: { error: `Invalid track for ${network}` } }; + } + + const { trackId } = networkTrackInfo[network][PostOrigin.ROOT]; + const proposalType = ProposalType.OPEN_GOV; + + const fetches = { + all: getOnChainPosts({ + listingLimit: LISTING_LIMIT, + network, + page, + proposalType, + sortBy, + trackNo: trackId, + trackStatus: 'All' + }), + closed: getOnChainPosts({ + listingLimit: LISTING_LIMIT, + network, + page, + proposalType, + sortBy, + trackNo: trackId, + trackStatus: CustomStatus.Closed + }), + submitted: getOnChainPosts({ + listingLimit: LISTING_LIMIT, + network, + page, + proposalType, + sortBy, + trackNo: trackId, + trackStatus: CustomStatus.Submitted + }), + voting: getOnChainPosts({ + listingLimit: LISTING_LIMIT, + network, + page, + proposalType, + sortBy, + trackNo: trackId, + trackStatus: CustomStatus.Voting + }) + }; + + const responseArr = await Promise.allSettled(Object.values(fetches)); + + const results = responseArr.map((result) => { + if (result.status === 'fulfilled') { + return result.value; + } else { + return { + data: null, + error: result.reason + } as IApiResponse; + } + }); + const props: IRootProps = { + network, + posts: {} + }; + Object.keys(fetches).forEach((key, index) => { + (props.posts as any)[key] = results[index]; + }); + + return { props }; +}; + +export interface IReferendumV2PostsByStatus { + all?: IApiResponse; + closed?: IApiResponse; + submitted?: IApiResponse; + voting?: IApiResponse; +} +interface IRootProps { + posts: IReferendumV2PostsByStatus; + network: string; + error?: string; +} + +const Root: FC = (props) => { + const { posts, error } = props; + const { setNetwork } = useNetworkContext(); + + useEffect(() => { + setNetwork(props.network); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + if (error) return ; + + if (!posts || Object.keys(posts).length === 0) return null; + return <> + + + ; +}; + +export default Root; \ No newline at end of file diff --git a/pages/settings/index.tsx b/pages/settings/index.tsx new file mode 100644 index 0000000000..84bf7db4a6 --- /dev/null +++ b/pages/settings/index.tsx @@ -0,0 +1,60 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. +import { Col, Divider, Row } from 'antd'; +import { GetServerSideProps } from 'next'; +import React, { FC, useEffect } from 'react'; + +import { getNetworkFromReqHeaders } from '~src/api-utils'; +import Account from '~src/components/Settings/Account'; +import Delete from '~src/components/Settings/Delete'; +import DemocracyUnlock from '~src/components/Settings/DemocracyUnlock'; +import Profile from '~src/components/Settings/Profile'; +import { useNetworkContext } from '~src/context'; +import SEOHead from '~src/global/SEOHead'; +import useHandleMetaMask from '~src/hooks/useHandleMetaMask'; + +interface Props { + network: string +} + +export const getServerSideProps: GetServerSideProps = async ({ req }) => { + const network = getNetworkFromReqHeaders(req.headers); + return { props: { network } }; +}; + +const Settings: FC = (props) => { + const { setNetwork, network } = useNetworkContext(); + + const metaMaskError = useHandleMetaMask(); + + useEffect(() => { + setNetwork(props.network); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( + <> + + + +

+ Settings +

+
+ + + + + + {!metaMaskError && ['moonbase', 'moonriver', 'moonbeam'].includes(network) ? <> : null} + + + + + ); +}; + +export default Settings; \ No newline at end of file diff --git a/pages/signup/index.tsx b/pages/signup/index.tsx new file mode 100644 index 0000000000..eb397f9dfc --- /dev/null +++ b/pages/signup/index.tsx @@ -0,0 +1,96 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. +/* eslint-disable @typescript-eslint/no-unused-vars */ + +import { Col, Row, Skeleton } from 'antd'; +import { GetServerSideProps } from 'next'; +import dynamic from 'next/dynamic'; +import React, { useEffect, useState } from 'react'; +import Web2Signup from 'src/components/Signup/Web2Signup'; +import { Wallet } from 'src/types'; + +import { getNetworkFromReqHeaders } from '~src/api-utils'; +import { useNetworkContext } from '~src/context'; +import SEOHead from '~src/global/SEOHead'; + +const WalletConnectSignup = dynamic(() => import('src/components/Signup/WalletConnectSignup'), { + loading: () => , + ssr: false +}); +const Web3Signup = dynamic(() => import('src/components/Signup/Web3Signup'), { + loading: () => , + ssr: false +}); +const MetamaskSignup = dynamic(() => import('src/components/Signup/MetamaskSignup'), { + loading: () => , + ssr: false +}); + +export const getServerSideProps: GetServerSideProps = async ({ req }) => { + const network = getNetworkFromReqHeaders(req.headers); + return { props: { network } }; +}; + +const Signup = (props : { network: string }) => { + const { setNetwork } = useNetworkContext(); + + useEffect(() => { + setNetwork(props.network); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const [displayWeb, setDisplayWeb] = useState(2); + const [chosenWallet, setChosenWallet] = useState(); + const [walletError, setWalletError] = useState(); + + const setDisplayWeb2 = () => setDisplayWeb(2); + + const onWalletSelect = (wallet: Wallet) => { + setChosenWallet(wallet); + + setDisplayWeb(3); + + }; + const [method, setMethod] = useState(''); + + useEffect(() => { + if(!method) return; + + if(method === 'web2') { + setDisplayWeb2(); + }else if(method === 'polkadotjs') { + onWalletSelect(Wallet.POLKADOT); + } + }, [method]); + + return ( + <> + + + + { displayWeb === 2 + ? : null} + + { + displayWeb === 3 && chosenWallet && <> + { + chosenWallet === Wallet.METAMASK ? + + : chosenWallet == Wallet.WALLETCONNECT ? + : + + } + + } + + + + ); +}; + +export default Signup; diff --git a/pages/small-spender/index.tsx b/pages/small-spender/index.tsx new file mode 100644 index 0000000000..dcf61307da --- /dev/null +++ b/pages/small-spender/index.tsx @@ -0,0 +1,121 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import { GetServerSideProps } from 'next'; +import { getOnChainPosts, IPostsListingResponse } from 'pages/api/v1/listing/on-chain-posts'; +import { IReferendumV2PostsByStatus } from 'pages/root'; +import React, { FC, useEffect } from 'react'; + +import { getNetworkFromReqHeaders } from '~src/api-utils'; +import TrackListing from '~src/components/Listing/Tracks/TrackListing'; +import { CustomStatus } from '~src/components/Listing/Tracks/TrackListingCard'; +import { useNetworkContext } from '~src/context'; +import { LISTING_LIMIT } from '~src/global/listingLimit'; +import { networkTrackInfo } from '~src/global/post_trackInfo'; +import { ProposalType } from '~src/global/proposalType'; +import SEOHead from '~src/global/SEOHead'; +import { sortValues } from '~src/global/sortOptions'; +import { IApiResponse, PostOrigin } from '~src/types'; +import { ErrorState } from '~src/ui-components/UIStates'; + +export const getServerSideProps: GetServerSideProps = async ({ req, query }) => { + const { page = 1, sortBy = sortValues.NEWEST } = query; + const network = getNetworkFromReqHeaders(req.headers); + + if(!networkTrackInfo[network][PostOrigin.SMALL_SPENDER]) { + return { props: { error: `Invalid track for ${network}` } }; + } + + const { trackId } = networkTrackInfo[network][PostOrigin.SMALL_SPENDER]; + const proposalType = ProposalType.OPEN_GOV; + + const fetches = { + all: getOnChainPosts({ + listingLimit: LISTING_LIMIT, + network, + page, + proposalType, + sortBy, + trackNo: trackId, + trackStatus: 'All' + }), + closed: getOnChainPosts({ + listingLimit: LISTING_LIMIT, + network, + page, + proposalType, + sortBy, + trackNo: trackId, + trackStatus: CustomStatus.Closed + }), + submitted: getOnChainPosts({ + listingLimit: LISTING_LIMIT, + network, + page, + proposalType, + sortBy, + trackNo: trackId, + trackStatus: CustomStatus.Submitted + }), + voting: getOnChainPosts({ + listingLimit: LISTING_LIMIT, + network, + page, + proposalType, + sortBy, + trackNo: trackId, + trackStatus: CustomStatus.Voting + }) + }; + + const responseArr = await Promise.allSettled(Object.values(fetches)); + + const results = responseArr.map((result) => { + if (result.status === 'fulfilled') { + return result.value; + } else { + return { + data: null, + error: result.reason + } as IApiResponse; + } + }); + const props: ISmallSpenderProps = { + network, + posts: {} + }; + Object.keys(fetches).forEach((key, index) => { + (props.posts as any)[key] = results[index]; + }); + + return { props }; +}; +interface ISmallSpenderProps { + posts: IReferendumV2PostsByStatus; + network: string; + error?: string; +} + +const SmallSpender: FC = (props) => { + const { posts, error } = props; + const { setNetwork } = useNetworkContext(); + + useEffect(() => { + setNetwork(props.network); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + if (error) return ; + + if (!posts || Object.keys(posts).length === 0) return null; + return <> + + + ; +}; + +export default SmallSpender; \ No newline at end of file diff --git a/pages/small-tipper/index.tsx b/pages/small-tipper/index.tsx new file mode 100644 index 0000000000..1a94b2ed2f --- /dev/null +++ b/pages/small-tipper/index.tsx @@ -0,0 +1,121 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import { GetServerSideProps } from 'next'; +import { getOnChainPosts, IPostsListingResponse } from 'pages/api/v1/listing/on-chain-posts'; +import { IReferendumV2PostsByStatus } from 'pages/root'; +import React, { FC, useEffect } from 'react'; + +import { getNetworkFromReqHeaders } from '~src/api-utils'; +import TrackListing from '~src/components/Listing/Tracks/TrackListing'; +import { CustomStatus } from '~src/components/Listing/Tracks/TrackListingCard'; +import { useNetworkContext } from '~src/context'; +import { LISTING_LIMIT } from '~src/global/listingLimit'; +import { networkTrackInfo } from '~src/global/post_trackInfo'; +import { ProposalType } from '~src/global/proposalType'; +import SEOHead from '~src/global/SEOHead'; +import { sortValues } from '~src/global/sortOptions'; +import { IApiResponse, PostOrigin } from '~src/types'; +import { ErrorState } from '~src/ui-components/UIStates'; + +export const getServerSideProps: GetServerSideProps = async ({ req, query }) => { + const { page = 1, sortBy = sortValues.NEWEST } = query; + const network = getNetworkFromReqHeaders(req.headers); + + if(!networkTrackInfo[network][PostOrigin.SMALL_TIPPER]) { + return { props: { error: `Invalid track for ${network}` } }; + } + + const { trackId } = networkTrackInfo[network][PostOrigin.SMALL_TIPPER]; + const proposalType = ProposalType.OPEN_GOV; + + const fetches = { + all: getOnChainPosts({ + listingLimit: LISTING_LIMIT, + network, + page, + proposalType, + sortBy, + trackNo: trackId, + trackStatus: 'All' + }), + closed: getOnChainPosts({ + listingLimit: LISTING_LIMIT, + network, + page, + proposalType, + sortBy, + trackNo: trackId, + trackStatus: CustomStatus.Closed + }), + submitted: getOnChainPosts({ + listingLimit: LISTING_LIMIT, + network, + page, + proposalType, + sortBy, + trackNo: trackId, + trackStatus: CustomStatus.Submitted + }), + voting: getOnChainPosts({ + listingLimit: LISTING_LIMIT, + network, + page, + proposalType, + sortBy, + trackNo: trackId, + trackStatus: CustomStatus.Voting + }) + }; + + const responseArr = await Promise.allSettled(Object.values(fetches)); + + const results = responseArr.map((result) => { + if (result.status === 'fulfilled') { + return result.value; + } else { + return { + data: null, + error: result.reason + } as IApiResponse; + } + }); + const props: ISmallTipperProps = { + network, + posts: {} + }; + Object.keys(fetches).forEach((key, index) => { + (props.posts as any)[key] = results[index]; + }); + + return { props }; +}; +interface ISmallTipperProps { + posts: IReferendumV2PostsByStatus; + network: string; + error?: string; +} + +const SmallTipper: FC = (props) => { + const { posts, error } = props; + const { setNetwork } = useNetworkContext(); + + useEffect(() => { + setNetwork(props.network); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + if (error) return ; + + if (!posts || Object.keys(posts).length === 0) return null; + return <> + + + ; +}; + +export default SmallTipper; \ No newline at end of file diff --git a/pages/staking-admin/index.tsx b/pages/staking-admin/index.tsx new file mode 100644 index 0000000000..4027f9afbf --- /dev/null +++ b/pages/staking-admin/index.tsx @@ -0,0 +1,121 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import { GetServerSideProps } from 'next'; +import { getOnChainPosts, IPostsListingResponse } from 'pages/api/v1/listing/on-chain-posts'; +import { IReferendumV2PostsByStatus } from 'pages/root'; +import React, { FC, useEffect } from 'react'; + +import { getNetworkFromReqHeaders } from '~src/api-utils'; +import TrackListing from '~src/components/Listing/Tracks/TrackListing'; +import { CustomStatus } from '~src/components/Listing/Tracks/TrackListingCard'; +import { useNetworkContext } from '~src/context'; +import { LISTING_LIMIT } from '~src/global/listingLimit'; +import { networkTrackInfo } from '~src/global/post_trackInfo'; +import { ProposalType } from '~src/global/proposalType'; +import SEOHead from '~src/global/SEOHead'; +import { sortValues } from '~src/global/sortOptions'; +import { IApiResponse, PostOrigin } from '~src/types'; +import { ErrorState } from '~src/ui-components/UIStates'; + +export const getServerSideProps: GetServerSideProps = async ({ req, query }) => { + const { page = 1, sortBy = sortValues.NEWEST } = query; + const network = getNetworkFromReqHeaders(req.headers); + + if(!networkTrackInfo[network][PostOrigin.STAKING_ADMIN]) { + return { props: { error: `Invalid track for ${network}` } }; + } + + const { trackId } = networkTrackInfo[network][PostOrigin.STAKING_ADMIN]; + const proposalType = ProposalType.OPEN_GOV; + + const fetches = { + all: getOnChainPosts({ + listingLimit: LISTING_LIMIT, + network, + page, + proposalType, + sortBy, + trackNo: trackId, + trackStatus: 'All' + }), + closed: getOnChainPosts({ + listingLimit: LISTING_LIMIT, + network, + page, + proposalType, + sortBy, + trackNo: trackId, + trackStatus: CustomStatus.Closed + }), + submitted: getOnChainPosts({ + listingLimit: LISTING_LIMIT, + network, + page, + proposalType, + sortBy, + trackNo: trackId, + trackStatus: CustomStatus.Submitted + }), + voting: getOnChainPosts({ + listingLimit: LISTING_LIMIT, + network, + page, + proposalType, + sortBy, + trackNo: trackId, + trackStatus: CustomStatus.Voting + }) + }; + + const responseArr = await Promise.allSettled(Object.values(fetches)); + + const results = responseArr.map((result) => { + if (result.status === 'fulfilled') { + return result.value; + } else { + return { + data: null, + error: result.reason + } as IApiResponse; + } + }); + const props: IStakingAdminProps = { + network, + posts: {} + }; + Object.keys(fetches).forEach((key, index) => { + (props.posts as any)[key] = results[index]; + }); + + return { props }; +}; +interface IStakingAdminProps { + posts: IReferendumV2PostsByStatus; + network: string; + error?: string; +} + +const StakingAdmin: FC = (props) => { + const { posts, error } = props; + const { setNetwork } = useNetworkContext(); + + useEffect(() => { + setNetwork(props.network); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + if (error) return ; + + if (!posts || Object.keys(posts).length === 0) return null; + return <> + + + ; +}; + +export default StakingAdmin; \ No newline at end of file diff --git a/pages/tech-comm-proposals/index.tsx b/pages/tech-comm-proposals/index.tsx new file mode 100644 index 0000000000..a6e5e7d439 --- /dev/null +++ b/pages/tech-comm-proposals/index.tsx @@ -0,0 +1,104 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. +import { Pagination } from 'antd'; +import { GetServerSideProps } from 'next'; +import { useRouter } from 'next/router'; +import { getOnChainPosts, IPostsListingResponse } from 'pages/api/v1/listing/on-chain-posts'; +import React, { FC, useEffect } from 'react'; + +import { getNetworkFromReqHeaders } from '~src/api-utils'; +import Listing from '~src/components/Listing'; +import { useNetworkContext } from '~src/context'; +import { LISTING_LIMIT } from '~src/global/listingLimit'; +import { ProposalType } from '~src/global/proposalType'; +import SEOHead from '~src/global/SEOHead'; +import { sortValues } from '~src/global/sortOptions'; +import { ErrorState } from '~src/ui-components/UIStates'; +import { handlePaginationChange } from '~src/util/handlePaginationChange'; + +export const getServerSideProps: GetServerSideProps = async ({ req, query }) => { + const { page = 1, sortBy = sortValues.NEWEST } = query; + const proposalType = ProposalType.TECH_COMMITTEE_PROPOSALS; + const network = getNetworkFromReqHeaders(req.headers); + const { data, error } = await getOnChainPosts({ + listingLimit: LISTING_LIMIT, + network, + page, + proposalType, + sortBy + }); + return { props: { data, error, network } }; +}; + +interface ITechCommProposalsProps { + data?: IPostsListingResponse; + error?: string; + network: string; +} + +const TechCommProposals: FC = (props) => { + const { data, error } = props; + const { setNetwork } = useNetworkContext(); + + useEffect(() => { + setNetwork(props.network); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const router = useRouter(); + + if (error) return ; + if (!data) return null; + const { posts, count } = data; + + const onPaginationChange = (page:number) => { + router.push({ + query:{ + page + } + }); + handlePaginationChange({ limit: LISTING_LIMIT, page }); + }; + + return ( + <> + +

On Chain Technical Committee Proposals

+ + {/* Intro and Create Post Button */} +
+

+ This is the place to discuss on-chain technical committee proposals. On-chain posts are automatically generated as soon as they are created on the chain. + Only the proposer is able to edit them. +

+
+ +
+
+

{ count } Tech Comm. Proposals

+
+ +
+ +
+ { + !!count && count > 0 && count > LISTING_LIMIT && + + } +
+
+
+ + ); +}; + +export default TechCommProposals; \ No newline at end of file diff --git a/pages/tech/[id].tsx b/pages/tech/[id].tsx new file mode 100644 index 0000000000..bf2ffde6dd --- /dev/null +++ b/pages/tech/[id].tsx @@ -0,0 +1,64 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import { GetServerSideProps } from 'next'; +import { getOnChainPost, IPostResponse } from 'pages/api/v1/posts/on-chain-post'; +import React, { FC, useEffect } from 'react'; +import Post from 'src/components/Post/Post'; +import { PostCategory } from 'src/global/post_categories'; +import BackToListingView from 'src/ui-components/BackToListingView'; +import { ErrorState, LoadingState } from 'src/ui-components/UIStates'; + +import { getNetworkFromReqHeaders } from '~src/api-utils'; +import { useNetworkContext } from '~src/context'; +import { noTitle } from '~src/global/noTitle'; +import { ProposalType } from '~src/global/proposalType'; +import SEOHead from '~src/global/SEOHead'; + +const proposalType = ProposalType.TECH_COMMITTEE_PROPOSALS; +export const getServerSideProps:GetServerSideProps = async ({ req, query }) => { + const { id } = query; + + const network = getNetworkFromReqHeaders(req.headers); + const { data, error } = await getOnChainPost({ + network, + postId: id, + proposalType + }); + return { props: { data, error, network } }; +}; + +interface ITechCommPostProps { + data: IPostResponse; + error?: string; + network: string; +} + +const TechCommPost: FC = (props) => { + const { data: post, error } = props; + const { setNetwork } = useNetworkContext(); + + useEffect(() => { + setNetwork(props.network); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + if (error) return ; + if (!post) return null; + + if (post) return (<> + + + + +
+ +
+ ); + + return
; + +}; + +export default TechCommPost; diff --git a/pages/terms-and-conditions.tsx b/pages/terms-and-conditions.tsx new file mode 100644 index 0000000000..cc8e4fb25a --- /dev/null +++ b/pages/terms-and-conditions.tsx @@ -0,0 +1,42 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. +import { GetServerSideProps } from 'next'; +import React, { FC, useEffect } from 'react'; + +import { getNetworkFromReqHeaders } from '~src/api-utils'; +import { TermsAndConditions } from '~src/components/LegalDocuments'; +import { useNetworkContext } from '~src/context'; +import SEOHead from '~src/global/SEOHead'; + +interface ITermsAndConditionsPage { + network: string; +} + +export const getServerSideProps: GetServerSideProps = async ({ req }) => { + const network = getNetworkFromReqHeaders(req.headers); + return { + props: { + network + } + }; +}; + +const TermsAndConditionsPage: FC = (props) => { + const { network } = props; + const { setNetwork } = useNetworkContext(); + + useEffect(() => { + setNetwork(network); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( + <> + + + + ); +}; + +export default TermsAndConditionsPage; \ No newline at end of file diff --git a/pages/terms-of-website.tsx b/pages/terms-of-website.tsx new file mode 100644 index 0000000000..e6e01ca247 --- /dev/null +++ b/pages/terms-of-website.tsx @@ -0,0 +1,41 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. +import { GetServerSideProps } from 'next'; +import React, { FC, useEffect } from 'react'; + +import { getNetworkFromReqHeaders } from '~src/api-utils'; +import { TermsOfWebsite } from '~src/components/LegalDocuments'; +import { useNetworkContext } from '~src/context'; +import SEOHead from '~src/global/SEOHead'; + +interface ITermsOfWebsitePage { + network: string; +} + +export const getServerSideProps: GetServerSideProps = async ({ req }) => { + const network = getNetworkFromReqHeaders(req.headers); + return { + props: { + network + } + }; +}; +const TermsOfWebsitePage: FC = (props) => { + const { network } = props; + const { setNetwork } = useNetworkContext(); + + useEffect(() => { + setNetwork(network); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( + <> + + + + ); +}; + +export default TermsOfWebsitePage; \ No newline at end of file diff --git a/pages/tip/[hash].tsx b/pages/tip/[hash].tsx new file mode 100644 index 0000000000..44da787107 --- /dev/null +++ b/pages/tip/[hash].tsx @@ -0,0 +1,64 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import { GetServerSideProps } from 'next'; +import { getOnChainPost, IPostResponse } from 'pages/api/v1/posts/on-chain-post'; +import React, { FC, useEffect } from 'react'; +import Post from 'src/components/Post/Post'; +import { PostCategory } from 'src/global/post_categories'; +import BackToListingView from 'src/ui-components/BackToListingView'; +import { ErrorState, LoadingState } from 'src/ui-components/UIStates'; + +import { getNetworkFromReqHeaders } from '~src/api-utils'; +import { useNetworkContext } from '~src/context'; +import { noTitle } from '~src/global/noTitle'; +import { ProposalType } from '~src/global/proposalType'; +import SEOHead from '~src/global/SEOHead'; + +const proposalType = ProposalType.TIPS; +export const getServerSideProps:GetServerSideProps = async ({ req, query }) => { + const { hash } = query; + + const network = getNetworkFromReqHeaders(req.headers); + const { data, error } = await getOnChainPost({ + network, + postId: hash, + proposalType + }); + return { props: { data, error, network } }; +}; + +interface ITipPostProps { + data: IPostResponse; + error?: string; + network: string; +} + +const TipPost: FC = (props) => { + const { data: post, error } = props; + const { setNetwork } = useNetworkContext(); + + useEffect(() => { + setNetwork(props.network); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + if (error) return ; + if (!post) return null; + + if (post) return (<> + + + + +
+ +
+ ); + + return
; + +}; + +export default TipPost; diff --git a/pages/tips/index.tsx b/pages/tips/index.tsx new file mode 100644 index 0000000000..9b8fc6ef26 --- /dev/null +++ b/pages/tips/index.tsx @@ -0,0 +1,105 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. +import { Pagination } from 'antd'; +import { GetServerSideProps } from 'next'; +import { useRouter } from 'next/router'; +import { getOnChainPosts, IPostsListingResponse } from 'pages/api/v1/listing/on-chain-posts'; +import React, { FC, useEffect } from 'react'; + +import { getNetworkFromReqHeaders } from '~src/api-utils'; +import Listing from '~src/components/Listing'; +import { useNetworkContext } from '~src/context'; +import { LISTING_LIMIT } from '~src/global/listingLimit'; +import { ProposalType } from '~src/global/proposalType'; +import SEOHead from '~src/global/SEOHead'; +import { sortValues } from '~src/global/sortOptions'; +import { ErrorState } from '~src/ui-components/UIStates'; +import { handlePaginationChange } from '~src/util/handlePaginationChange'; + +export const getServerSideProps: GetServerSideProps = async ({ req, query }) => { + const { page = 1, sortBy = sortValues.NEWEST } = query; + const proposalType = ProposalType.TIPS; + const network = getNetworkFromReqHeaders(req.headers); + const { data, error } = await getOnChainPosts({ + listingLimit: LISTING_LIMIT, + network, + page, + proposalType, + sortBy + }); + return { props: { data, error, network, page } }; +}; + +interface ITipsProps { + data?: IPostsListingResponse; + error?: string; + network: string; + page: number; +} + +const Tips: FC = (props) => { + const { data, error, page } = props; + const { setNetwork } = useNetworkContext(); + + useEffect(() => { + setNetwork(props.network); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const router = useRouter(); + + if (error) return ; + if (!data) return null; + const { posts, count } = data; + + const onPaginationChange = (page:number) => { + router.push({ + query:{ + page + } + }); + handlePaginationChange({ limit: LISTING_LIMIT, page }); + }; + + return ( + <> + +

On Chain Tips

+ + {/* Intro and Create Post Button */} +
+

+ This is the place to discuss on-chain tips. Tip posts are automatically generated as soon as they are created on-chain. + Only the proposer is able to edit them. +

+
+ +
+
+

{ count } Tips

+
+ +
+ +
+ { + !!count && count > 0 && count > LISTING_LIMIT && + + } +
+
+
+ + ); +}; + +export default Tips; \ No newline at end of file diff --git a/pages/tracker/index.tsx b/pages/tracker/index.tsx new file mode 100644 index 0000000000..2c9fd35057 --- /dev/null +++ b/pages/tracker/index.tsx @@ -0,0 +1,134 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import { Col,Row } from 'antd'; +import { GetServerSideProps } from 'next'; +import React, { FC, useEffect, useState } from 'react'; + +import { getNetworkFromReqHeaders } from '~src/api-utils'; +import ListingContainer from '~src/components/Tracker/ListingContainer'; +import { useNetworkContext } from '~src/context'; +import { ProposalType } from '~src/global/proposalType'; +import SEOHead from '~src/global/SEOHead'; + +interface ITrackerContainerProps { + className?: string; + network: string; +} + +export const getServerSideProps: GetServerSideProps = async ({ req }) => { + const network = getNetworkFromReqHeaders(req.headers); + return { props: { network } }; +}; + +const TrackerContainer: FC = (props) => { + const { className } = props; + const { setNetwork } = useNetworkContext(); + + useEffect(() => { + setNetwork(props.network); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const [ids, setIds] = useState({ + bounty: [], + motion: [], + proposal: [], + referendum: [], + techCommitteeProposal: [], + tipProposal: [], + treasuryProposal: [] + }); + + useEffect(() => { + let trackerMap: any = {}; + if(typeof window !== undefined) { + trackerMap = JSON.parse(global.window?.localStorage.getItem('trackMap') || '{}'); + } + const ids: any = { + bounty: [], + motion: [], + proposal: [], + referendum: [], + techCommitteeProposal: [], + tipProposal: [], + treasuryProposal: [] + }; + Object.entries(trackerMap || {}).forEach(([key, value]) => { + ids[key] = Object.keys(value || {}).map((k) => key === 'tipProposal'? String(k): Number(k)); + }); + setIds(ids); + }, []); + + return ( + <> + +
+

Tracker

+ + {/* Intro and Create Post Button */} +
+

+ This is a place to keep track of on chain posts. +

+
+ + + + + + + + + + + + + + + + + + + + + + + +
+ + ); + +}; + +export default TrackerContainer; diff --git a/pages/treasurer/index.tsx b/pages/treasurer/index.tsx new file mode 100644 index 0000000000..5035b7a38f --- /dev/null +++ b/pages/treasurer/index.tsx @@ -0,0 +1,121 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import { GetServerSideProps } from 'next'; +import { getOnChainPosts, IPostsListingResponse } from 'pages/api/v1/listing/on-chain-posts'; +import { IReferendumV2PostsByStatus } from 'pages/root'; +import React, { FC, useEffect } from 'react'; + +import { getNetworkFromReqHeaders } from '~src/api-utils'; +import TrackListing from '~src/components/Listing/Tracks/TrackListing'; +import { CustomStatus } from '~src/components/Listing/Tracks/TrackListingCard'; +import { useNetworkContext } from '~src/context'; +import { LISTING_LIMIT } from '~src/global/listingLimit'; +import { networkTrackInfo } from '~src/global/post_trackInfo'; +import { ProposalType } from '~src/global/proposalType'; +import SEOHead from '~src/global/SEOHead'; +import { sortValues } from '~src/global/sortOptions'; +import { IApiResponse, PostOrigin } from '~src/types'; +import { ErrorState } from '~src/ui-components/UIStates'; + +export const getServerSideProps: GetServerSideProps = async ({ req, query }) => { + const { page = 1, sortBy = sortValues.NEWEST } = query; + const network = getNetworkFromReqHeaders(req.headers); + + if(!networkTrackInfo[network][PostOrigin.TREASURER]) { + return { props: { error: `Invalid track for ${network}` } }; + } + + const { trackId } = networkTrackInfo[network][PostOrigin.TREASURER]; + const proposalType = ProposalType.OPEN_GOV; + + const fetches = { + all: getOnChainPosts({ + listingLimit: LISTING_LIMIT, + network, + page, + proposalType, + sortBy, + trackNo: trackId, + trackStatus: 'All' + }), + closed: getOnChainPosts({ + listingLimit: LISTING_LIMIT, + network, + page, + proposalType, + sortBy, + trackNo: trackId, + trackStatus: CustomStatus.Closed + }), + submitted: getOnChainPosts({ + listingLimit: LISTING_LIMIT, + network, + page, + proposalType, + sortBy, + trackNo: trackId, + trackStatus: CustomStatus.Submitted + }), + voting: getOnChainPosts({ + listingLimit: LISTING_LIMIT, + network, + page, + proposalType, + sortBy, + trackNo: trackId, + trackStatus: CustomStatus.Voting + }) + }; + + const responseArr = await Promise.allSettled(Object.values(fetches)); + + const results = responseArr.map((result) => { + if (result.status === 'fulfilled') { + return result.value; + } else { + return { + data: null, + error: result.reason + } as IApiResponse; + } + }); + const props: ITreasurerProps = { + network, + posts: {} + }; + Object.keys(fetches).forEach((key, index) => { + (props.posts as any)[key] = results[index]; + }); + + return { props }; +}; +interface ITreasurerProps { + posts: IReferendumV2PostsByStatus; + network: string; + error?: string; +} + +const Treasurer: FC = (props) => { + const { posts, error } = props; + const { setNetwork } = useNetworkContext(); + + useEffect(() => { + setNetwork(props.network); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + if (error) return ; + + if (!posts || Object.keys(posts).length === 0) return null; + return <> + + + ; +}; + +export default Treasurer; \ No newline at end of file diff --git a/pages/treasury-proposals/index.tsx b/pages/treasury-proposals/index.tsx new file mode 100644 index 0000000000..bf69a3a7dd --- /dev/null +++ b/pages/treasury-proposals/index.tsx @@ -0,0 +1,132 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import { Pagination } from 'antd'; +import { GetServerSideProps } from 'next'; +import dynamic from 'next/dynamic'; +import { useRouter } from 'next/router'; +import { getOnChainPosts, IPostsListingResponse } from 'pages/api/v1/listing/on-chain-posts'; +import React, { FC, useContext, useEffect } from 'react'; + +import { getNetworkFromReqHeaders } from '~src/api-utils'; +import Listing from '~src/components/Listing'; +import { useNetworkContext } from '~src/context'; +import { NetworkContext } from '~src/context/NetworkContext'; +import { LISTING_LIMIT } from '~src/global/listingLimit'; +import { ProposalType } from '~src/global/proposalType'; +import SEOHead from '~src/global/SEOHead'; +import { sortValues } from '~src/global/sortOptions'; +import { ErrorState } from '~src/ui-components/UIStates'; +import { handlePaginationChange } from '~src/util/handlePaginationChange'; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +const TreasuryProposalFormButton = dynamic(() => import('src/components/CreateTreasuryProposal/TreasuryProposalFormButton'), { + ssr: false +}); + +const TreasuryOverview = dynamic(() => import('src/components/Home/TreasuryOverview'), { + ssr: false +}); + +export const getServerSideProps: GetServerSideProps = async ({ req, query }) => { + const { page = 1, sortBy = sortValues.NEWEST } = query; + const proposalType = ProposalType.TREASURY_PROPOSALS; + const network = getNetworkFromReqHeaders(req.headers); + const { data, error } = await getOnChainPosts({ + listingLimit: LISTING_LIMIT, + network, + page, + proposalType, + sortBy + }); + return { props: { data, error, network } }; +}; + +interface ITreasuryProps { + data?: IPostsListingResponse; + error?: string; + network: string; +} + +const Treasury: FC = (props) => { + const { data, error } = props; + const { setNetwork } = useNetworkContext(); + + useEffect(() => { + setNetwork(props.network); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const router = useRouter(); + + const { network } = useContext(NetworkContext); + + if (error) return ; + if (!data) return null; + const { posts, count } = data; + + const onPaginationChange = (page:number) => { + router.push({ + query:{ + page + } + }); + handlePaginationChange({ limit: LISTING_LIMIT, page }); + }; + + return ( + <> + + +
+

On Chain Treasury Proposals

+ {/* {['kusama', 'polkadot'].includes(network) && } */} +
+ + {/* Intro and Create Post Button */} +
+

+ This is the place to discuss on-chain treasury proposals. On-chain posts are automatically generated as soon as they are created on the chain. + Only the proposer is able to edit them. + { + ['moonbeam', 'moonriver', 'moonbase'].includes(network)? +

+ : null + } +

+
+ + {/* Treasury Overview Cards */} + + +
+
+

{ count } Treasury Proposals

+
+ +
+ +
+ { + !!count && count > 0 && count > LISTING_LIMIT && + + } +
+
+
+ + ); +}; + +export default Treasury; \ No newline at end of file diff --git a/pages/treasury/[id].tsx b/pages/treasury/[id].tsx new file mode 100644 index 0000000000..da7ef0a74b --- /dev/null +++ b/pages/treasury/[id].tsx @@ -0,0 +1,64 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import { GetServerSideProps } from 'next'; +import { getOnChainPost, IPostResponse } from 'pages/api/v1/posts/on-chain-post'; +import React, { FC, useEffect } from 'react'; +import Post from 'src/components/Post/Post'; +import { PostCategory } from 'src/global/post_categories'; +import BackToListingView from 'src/ui-components/BackToListingView'; +import { ErrorState, LoadingState } from 'src/ui-components/UIStates'; + +import { getNetworkFromReqHeaders } from '~src/api-utils'; +import { useNetworkContext } from '~src/context'; +import { noTitle } from '~src/global/noTitle'; +import { ProposalType } from '~src/global/proposalType'; +import SEOHead from '~src/global/SEOHead'; + +const proposalType = ProposalType.TREASURY_PROPOSALS; +export const getServerSideProps:GetServerSideProps = async ({ req, query }) => { + const { id } = query; + + const network = getNetworkFromReqHeaders(req.headers); + const { data, error } = await getOnChainPost({ + network, + postId: id, + proposalType + }); + return { props: { data, error, network } }; +}; + +interface ITreasuryPostProps { + data: IPostResponse; + error?: string; + network: string; +} + +const TreasuryPost: FC = (props) => { + const { data: post, error } = props; + const { setNetwork } = useNetworkContext(); + + useEffect(() => { + setNetwork(props.network); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + if (error) return ; + if (!post) return null; + + if (post) return (<> + + + + +
+ +
+ ); + + return
; + +}; + +export default TreasuryPost; diff --git a/pages/undo-email-change/[token].tsx b/pages/undo-email-change/[token].tsx new file mode 100644 index 0000000000..b82c2df2f2 --- /dev/null +++ b/pages/undo-email-change/[token].tsx @@ -0,0 +1,83 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. +import { WarningOutlined } from '@ant-design/icons'; +import { Row } from 'antd'; +import { GetServerSideProps } from 'next'; +import { useRouter } from 'next/router'; +import React, { useCallback, useEffect, useState } from 'react'; +import { useNetworkContext, useUserDetailsContext } from 'src/context'; +import queueNotification from 'src/ui-components/QueueNotification'; + +import { getNetworkFromReqHeaders } from '~src/api-utils'; +import { UndoEmailChangeResponseType } from '~src/auth/types'; +import SEOHead from '~src/global/SEOHead'; +import { handleTokenChange } from '~src/services/auth.service'; +import { NotificationStatus } from '~src/types'; +import FilteredError from '~src/ui-components/FilteredError'; +import Loader from '~src/ui-components/Loader'; +import nextApiClientFetch from '~src/util/nextApiClientFetch'; + +export const getServerSideProps: GetServerSideProps = async ({ req }) => { + const network = getNetworkFromReqHeaders(req.headers); + return { props: { network } }; +}; + +const UndoEmailChange = ({ network }: { network: string }) => { + const { setNetwork } = useNetworkContext(); + + useEffect(() => { + setNetwork(network); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const router = useRouter(); + const [error, setError] = useState(''); + const currentUser = useUserDetailsContext(); + + const handleUndoEmailChange = useCallback(async () => { + const { data , error } = await nextApiClientFetch( 'api/v1/auth/actions/requestResetPassword'); + if(error) { + console.error('Undo email change error ', error); + setError(error); + queueNotification({ + header: 'Error!', + message: 'There was an error undoing email change. Please try again.', + status: NotificationStatus.SUCCESS + }); + } + + if (data) { + handleTokenChange(data.token, currentUser); + queueNotification({ + header: 'Success!', + message: data.message, + status: NotificationStatus.SUCCESS + }); + router.replace('/'); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [currentUser]); + + useEffect(() => { + handleUndoEmailChange(); + }, [handleUndoEmailChange]); + + return ( + <> + + + { error ?
+

+ + +

+
+ : + } +
+ + ); +}; + +export default UndoEmailChange; \ No newline at end of file diff --git a/pages/user/[username].tsx b/pages/user/[username].tsx new file mode 100644 index 0000000000..17da444ea1 --- /dev/null +++ b/pages/user/[username].tsx @@ -0,0 +1,172 @@ +// Copyright 2019-2025 @polkassembly/polkassembly authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import { Select, Tabs } from 'antd'; +import { GetServerSideProps } from 'next'; +import { getUserProfileWithUsername } from 'pages/api/v1/auth/data/userProfileWithUsername'; +import { getDefaultUserPosts, getUserPosts, IUserPostsListingResponse } from 'pages/api/v1/listing/user-posts'; +import React, { FC, useEffect, useState } from 'react'; +import { useNetworkContext } from 'src/context'; +import styled from 'styled-components'; + +import { getNetworkFromReqHeaders } from '~src/api-utils'; +import { ProfileDetailsResponse } from '~src/auth/types'; +import PostsTab from '~src/components/User/PostsTab'; +import Details from '~src/components/UserProfile/Details'; +import { EGovType } from '~src/global/proposalType'; +import SEOHead from '~src/global/SEOHead'; +import CountBadgePill from '~src/ui-components/CountBadgePill'; +import ErrorAlert from '~src/ui-components/ErrorAlert'; + +interface IUserProfileProps { + userPosts: { + data: IUserPostsListingResponse, + error: string | null; + }; + userProfile: { + data: ProfileDetailsResponse; + error: string | null; + } + network: string; + className?: string; +} + +export const getServerSideProps: GetServerSideProps = async (context) => { + const username = context.params?.username; + if (!username) { + return { props: { + error: 'No username provided' + } }; + } + const req = context.req; + const network = getNetworkFromReqHeaders(req.headers); + + const userProfile = await getUserProfileWithUsername(username.toString()); + const userPosts = await getUserPosts({ + addresses: userProfile?.data?.addresses || [], + network, + userId: userProfile?.data?.user_id + }); + const props: IUserProfileProps = { + network, + userPosts: { + data: userPosts.data || getDefaultUserPosts(), + error: userPosts.error + }, + userProfile: { + data: userProfile.data || { + addresses: [], + badges: [], + bio: '', + image: '', + social_links: [], + title: '', + user_id: 0, + username: String(username) + }, + error: userProfile.error + } + }; + return { + props + }; +}; + +const UserProfile: FC = (props) => { + const { userPosts, network, userProfile, className } = props; + const { setNetwork } = useNetworkContext(); + const [selectedGov, setSelectedGov] = useState(EGovType.GOV1); + + useEffect(() => { + setNetwork(network); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + if (userPosts.error || userProfile.error) { + return ( + + ); + } + const tabItems = Object.entries(userPosts.data?.[selectedGov]).map(([key, value]) => { + if (!value) return null; + let count = 0; + if (Array.isArray(value)) { + count = value.length; + } else { + Object.values(value).forEach((v) => { + if (v && Array.isArray(v)) { + count += v.length; + } + }); + } + return { + children: ( + + ), + key: key, + label: + }; + }); + return ( + <> + +
+
+
+
+

+ Activity +

+