├── src ├── foundation │ └── mod.rs ├── openapi │ ├── mod.rs │ └── api.rs ├── errors │ ├── mod.rs │ └── definitions.rs ├── commons │ ├── mod.rs │ └── authorization.rs ├── llm │ ├── mod.rs │ ├── openai.rs │ └── suggestions.rs ├── auth │ ├── mod.rs │ ├── jwt.rs │ ├── engine.rs │ └── core.rs ├── graphql │ ├── mod.rs │ ├── mutations │ │ ├── mod.rs │ │ └── auth.rs │ ├── queries │ │ ├── mod.rs │ │ ├── ai_functions.rs │ │ └── resources.rs │ ├── auth.rs │ └── subscription.rs ├── system │ ├── mod.rs │ ├── schema.rs │ ├── prelude.rs │ ├── members.rs │ ├── subscriptions.rs │ └── core.rs ├── sdk │ ├── mod.rs │ ├── organization.rs │ ├── utilities.rs │ ├── labels.rs │ ├── activity.rs │ ├── team.rs │ ├── project.rs │ ├── task.rs │ ├── member.rs │ └── loaders.rs ├── lib.rs ├── config.rs ├── handlers.rs ├── main.rs └── statics.rs ├── .clippy.toml ├── .dockerignore ├── .selfignore ├── devenv.yaml ├── public ├── plexo_gh_banner.png ├── plexo_platform_demo.png └── plexo_platform_demo_2.png ├── .envrc ├── .gitmodules ├── .gitignore ├── devenv.nix ├── migrations ├── 20230917042610_activity.sql └── 20221228013635_init.sql ├── LICENSE-MIT ├── docker-compose.yml ├── Cargo.toml ├── .github └── workflows │ ├── registry-flavor-demo.yml │ └── registry-docker.yml ├── Dockerfile ├── constitution.md ├── README.md ├── LICENSE-APACHE └── openapi.json /src/foundation/mod.rs: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/openapi/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod api; 2 | -------------------------------------------------------------------------------- /src/errors/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod definitions; 2 | -------------------------------------------------------------------------------- /.clippy.toml: -------------------------------------------------------------------------------- 1 | 2 | too-many-arguments-threshold=15 -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | /.env 2 | target 3 | Secrets.toml -------------------------------------------------------------------------------- /src/commons/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod authorization; 2 | -------------------------------------------------------------------------------- /src/llm/mod.rs: -------------------------------------------------------------------------------- 1 | mod openai; 2 | pub mod suggestions; 3 | -------------------------------------------------------------------------------- /.selfignore: -------------------------------------------------------------------------------- 1 | plexo-platform 2 | Cargo.lock 3 | output.json 4 | output.yaml -------------------------------------------------------------------------------- /src/auth/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod core; 2 | pub mod engine; 3 | pub mod jwt; 4 | -------------------------------------------------------------------------------- /devenv.yaml: -------------------------------------------------------------------------------- 1 | inputs: 2 | nixpkgs: 3 | url: github:NixOS/nixpkgs/nixpkgs-unstable -------------------------------------------------------------------------------- /public/plexo_gh_banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/minskylab/plexo-core/HEAD/public/plexo_gh_banner.png -------------------------------------------------------------------------------- /src/graphql/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod auth; 2 | pub mod mutations; 3 | pub mod queries; 4 | pub mod subscription; 5 | -------------------------------------------------------------------------------- /.envrc: -------------------------------------------------------------------------------- 1 | watch_file devenv.nix 2 | watch_file devenv.yaml 3 | watch_file devenv.lock 4 | eval "$(devenv print-dev-env)" -------------------------------------------------------------------------------- /public/plexo_platform_demo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/minskylab/plexo-core/HEAD/public/plexo_platform_demo.png -------------------------------------------------------------------------------- /public/plexo_platform_demo_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/minskylab/plexo-core/HEAD/public/plexo_platform_demo_2.png -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "plexo-platform"] 2 | path = plexo-platform 3 | url = https://github.com/minskylab/plexo-platform 4 | -------------------------------------------------------------------------------- /src/system/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod core; 2 | pub mod members; 3 | pub mod prelude; 4 | pub mod schema; 5 | pub mod subscriptions; 6 | -------------------------------------------------------------------------------- /src/sdk/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod activity; 2 | pub mod labels; 3 | pub mod loaders; 4 | pub mod member; 5 | pub mod project; 6 | pub mod task; 7 | pub mod team; 8 | pub mod utilities; 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | .env 3 | /postgres-data 4 | # Devenv 5 | .devenv* 6 | devenv.local.nix 7 | data 8 | .blob 9 | Secrets.toml 10 | .vscode 11 | .DS_Store 12 | .sqlx 13 | .idea -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod auth; 2 | pub mod commons; 3 | pub mod config; 4 | pub mod errors; 5 | pub mod graphql; 6 | pub mod handlers; 7 | pub mod llm; 8 | pub mod openapi; 9 | pub mod sdk; 10 | pub mod statics; 11 | pub mod system; 12 | -------------------------------------------------------------------------------- /src/graphql/mutations/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod auth; 2 | pub mod resources; 3 | 4 | use async_graphql::MergedObject; 5 | 6 | use self::{auth::AuthMutation, resources::ResourcesMutation}; 7 | 8 | // use super::{auth_mutation:i:AuthMutation, resources_mutation::ResourcesMutation}; 9 | 10 | #[derive(MergedObject, Default)] 11 | pub struct MutationRoot(ResourcesMutation, AuthMutation); 12 | -------------------------------------------------------------------------------- /src/sdk/organization.rs: -------------------------------------------------------------------------------- 1 | use async_graphql::{ComplexObject, SimpleObject}; 2 | use chrono::{DateTime, Utc}; 3 | use uuid::Uuid; 4 | 5 | #[derive(SimpleObject, Clone)] 6 | #[graphql(complex)] 7 | pub struct Organization { 8 | pub id: Uuid, 9 | pub created_at: DateTime, 10 | pub updated_at: DateTime, 11 | 12 | pub name: String, 13 | } 14 | 15 | #[ComplexObject] 16 | impl Organization {} 17 | -------------------------------------------------------------------------------- /src/graphql/queries/mod.rs: -------------------------------------------------------------------------------- 1 | use async_graphql::MergedObject; 2 | 3 | use self::{ai_functions::AIFunctionsQuery, resources::ResourcesQuery}; 4 | 5 | pub mod ai_functions; 6 | pub mod resources; 7 | 8 | // use self::{auth::AuthMutation, resources::ResourcesMutation}; 9 | 10 | // use super::{auth_mutation:i:AuthMutation, resources_mutation::ResourcesMutation}; 11 | #[derive(MergedObject, Default)] 12 | pub struct QueryRoot(ResourcesQuery, AIFunctionsQuery); 13 | -------------------------------------------------------------------------------- /src/errors/definitions.rs: -------------------------------------------------------------------------------- 1 | use thiserror::Error; 2 | 3 | #[derive(Error, Debug)] 4 | pub enum PlexoAppError { 5 | #[error("Authorization token not provided")] 6 | MissingAuthorizationToken, 7 | #[error("Invalid authorization token")] 8 | InvalidAuthorizationToken, 9 | #[error("Email already in use")] 10 | EmailAlreadyInUse, 11 | #[error("Password isn't valid")] 12 | InvalidPassword, 13 | #[error("Email not found")] 14 | EmailNotFound, 15 | #[error("Email already exists")] 16 | EmailAlreadyExists, 17 | #[error("Poem error")] 18 | PoemError(#[from] poem::error::NotFoundError), 19 | } 20 | -------------------------------------------------------------------------------- /devenv.nix: -------------------------------------------------------------------------------- 1 | { pkgs, ... }: 2 | 3 | { 4 | # https://devenv.sh/basics/ 5 | env.GREET = "devenv"; 6 | 7 | # https://devenv.sh/packages/ 8 | packages = [ 9 | pkgs.git 10 | pkgs.rustc 11 | pkgs.cargo 12 | ]; 13 | 14 | enterShell = '' 15 | hello 16 | git --version 17 | ''; 18 | 19 | # https://devenv.sh/languages/ 20 | # languages.nix.enable = true; 21 | 22 | # https://devenv.sh/scripts/ 23 | # scripts.hello.exec = "echo hello from $GREET"; 24 | 25 | # https://devenv.sh/pre-commit-hooks/ 26 | # pre-commit.hooks.shellcheck.enable = true; 27 | 28 | # https://devenv.sh/processes/ 29 | # processes.ping.exec = "ping example.com"; 30 | } 31 | -------------------------------------------------------------------------------- /src/graphql/auth.rs: -------------------------------------------------------------------------------- 1 | use async_graphql::{Context, Result}; 2 | use uuid::Uuid; 3 | 4 | use crate::{auth::core::PlexoAuthToken, errors::definitions::PlexoAppError, system::core::Engine}; 5 | 6 | pub fn extract_context(ctx: &Context<'_>) -> Result<(Engine, Uuid)> { 7 | let Ok(auth_token) = &ctx.data::() else { 8 | return Err(PlexoAppError:: MissingAuthorizationToken.into()); 9 | }; 10 | 11 | let plexo_engine = ctx.data::()?.to_owned(); 12 | 13 | let Ok(claims) = plexo_engine.auth.extract_claims(auth_token) else { 14 | return Err(PlexoAppError:: InvalidAuthorizationToken.into()); 15 | }; 16 | 17 | let member_id = claims.member_id(); 18 | 19 | Ok((plexo_engine, member_id)) 20 | } 21 | -------------------------------------------------------------------------------- /migrations/20230917042610_activity.sql: -------------------------------------------------------------------------------- 1 | -- Add migration script here 2 | 3 | CREATE TABLE public.activity ( 4 | id uuid DEFAULT gen_random_uuid() NOT NULL, 5 | created_at timestamp with time zone DEFAULT now() NOT NULL, 6 | updated_at timestamp with time zone DEFAULT now() NOT NULL, 7 | 8 | member_id uuid NOT NULL, 9 | resource_id uuid NOT NULL, 10 | 11 | operation text NOT NULL, 12 | resource_type text NOT NULL 13 | ); 14 | 15 | 16 | ALTER TABLE ONLY public.activity 17 | ADD CONSTRAINT activity_pkey PRIMARY KEY (id); 18 | 19 | ALTER TABLE ONLY public.activity 20 | ADD CONSTRAINT activity_member_id_fkey FOREIGN KEY (member_id) REFERENCES public.members(id) ON DELETE CASCADE; 21 | 22 | CREATE INDEX activity_member_id_idx ON public.activity USING btree (member_id); 23 | 24 | CREATE INDEX activity_resource_id_idx ON public.activity USING btree (resource_id); -------------------------------------------------------------------------------- /src/sdk/utilities.rs: -------------------------------------------------------------------------------- 1 | use chrono::{DateTime, FixedOffset, NaiveDateTime, TimeZone, Utc}; 2 | use sqlx::types::time::OffsetDateTime; 3 | 4 | pub struct DateTimeBridge; 5 | 6 | impl DateTimeBridge { 7 | pub fn to_string(date_time: DateTime) -> String { 8 | date_time.to_rfc3339() 9 | } 10 | 11 | pub fn from_string(date_time: String) -> DateTime { 12 | DateTime::parse_from_rfc3339(&date_time).unwrap() 13 | } 14 | 15 | pub fn from_offset_date_time(offset_date_time: OffsetDateTime) -> DateTime { 16 | let naive_date_time = 17 | NaiveDateTime::from_timestamp_millis(offset_date_time.unix_timestamp() * 1000).unwrap(); 18 | 19 | // TimeZone::from_utc_datetime(&Utc, &naive_date_time) 20 | Utc.from_utc_datetime(&naive_date_time) 21 | // DateTime::::from_utc(naive_date_time, Utc) 22 | } 23 | 24 | pub fn from_date_time(date_time: DateTime) -> OffsetDateTime { 25 | OffsetDateTime::from_unix_timestamp(date_time.timestamp()).unwrap() 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/commons/authorization.rs: -------------------------------------------------------------------------------- 1 | use cookie::Cookie; 2 | use poem::http::HeaderMap; 3 | 4 | use crate::auth::core::{PlexoAuthToken, COOKIE_SESSION_TOKEN_NAME}; 5 | 6 | pub fn get_token_from_headers(headers: &HeaderMap) -> Option { 7 | headers 8 | .get("Authorization") 9 | .and_then(|value| value.to_str().map(|s| PlexoAuthToken(s.to_string())).ok()) 10 | } 11 | 12 | pub fn get_token_from_cookie(headers: &HeaderMap) -> Option { 13 | let raw_cookie = headers.get("Cookie").and_then(|c| c.to_str().ok())?; 14 | 15 | get_token_from_raw_cookie(raw_cookie) 16 | } 17 | 18 | pub fn get_token_from_raw_cookie(raw_cookie: &str) -> Option { 19 | for cookie in Cookie::split_parse(raw_cookie) { 20 | let Ok(cookie) = cookie else { 21 | println!("Error parsing cookie"); 22 | continue; 23 | }; 24 | 25 | if cookie.name() == COOKIE_SESSION_TOKEN_NAME { 26 | return Some(PlexoAuthToken(cookie.value().to_string())); 27 | } 28 | } 29 | 30 | None 31 | } 32 | -------------------------------------------------------------------------------- /LICENSE-MIT: -------------------------------------------------------------------------------- 1 | Permission is hereby granted, free of charge, to any 2 | person obtaining a copy of this software and associated 3 | documentation files (the "Software"), to deal in the 4 | Software without restriction, including without 5 | limitation the rights to use, copy, modify, merge, 6 | publish, distribute, sublicense, and/or sell copies of 7 | the Software, and to permit persons to whom the Software 8 | is furnished to do so, subject to the following 9 | conditions: 10 | 11 | The above copyright notice and this permission notice 12 | shall be included in all copies or substantial portions 13 | of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF 16 | ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED 17 | TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 18 | PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT 19 | SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 20 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR 22 | IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 23 | DEALINGS IN THE SOFTWARE. 24 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.7' 2 | services: 3 | postgres: 4 | image: postgres:15.2 5 | restart: always 6 | environment: 7 | - POSTGRES_USER=plexo 8 | - POSTGRES_PASSWORD=example 9 | logging: 10 | options: 11 | max-size: 10m 12 | max-file: "3" 13 | ports: 14 | - '5438:5432' 15 | volumes: 16 | - ./postgres-data:/var/lib/postgresql/data 17 | - ./sql/create_tables.sql:/docker-entrypoint-initdb.d/create_tables.sql 18 | # copy the sql script to fill tables 19 | 20 | 21 | pgadmin: 22 | container_name: pgadmin4_container 23 | image: dpage/pgadmin4 24 | restart: always 25 | environment: 26 | PGADMIN_DEFAULT_EMAIL: admin@admin.com 27 | PGADMIN_DEFAULT_PASSWORD: root 28 | ports: 29 | - "5050:80" 30 | plexo: 31 | build: 32 | context: . 33 | dockerfile: Dockerfile 34 | restart: always 35 | environment: 36 | DATABASE_URL: postgres://plexo:example@postgres:5432/plexo 37 | env_file: 38 | - .env 39 | volumes: 40 | - ./data:/data 41 | depends_on: 42 | - postgres 43 | ports: 44 | - 8080:8080 -------------------------------------------------------------------------------- /src/system/schema.rs: -------------------------------------------------------------------------------- 1 | use async_graphql::{dataloader::DataLoader, Schema}; 2 | 3 | use crate::{ 4 | graphql::{mutations::MutationRoot, queries::QueryRoot, subscription::SubscriptionRoot}, 5 | sdk::loaders::{LabelLoader, MemberLoader, ProjectLoader, TaskLoader, TeamLoader}, 6 | system::core::Engine, 7 | }; 8 | 9 | pub trait GraphQLSchema { 10 | fn graphql_api_schema(&self) -> Schema; 11 | } 12 | 13 | impl GraphQLSchema for Engine { 14 | fn graphql_api_schema(&self) -> Schema { 15 | Schema::build( 16 | QueryRoot::default(), 17 | MutationRoot::default(), 18 | SubscriptionRoot, 19 | ) 20 | .data(self.clone()) // TODO: Optimize this 21 | .data(DataLoader::new(TaskLoader::new(self.clone()), tokio::spawn)) 22 | .data(DataLoader::new( 23 | ProjectLoader::new(self.clone()), 24 | tokio::spawn, 25 | )) 26 | .data(DataLoader::new( 27 | LabelLoader::new(self.clone()), 28 | tokio::spawn, 29 | )) 30 | .data(DataLoader::new( 31 | MemberLoader::new(self.clone()), 32 | tokio::spawn, 33 | )) 34 | .data(DataLoader::new(TeamLoader::new(self.clone()), tokio::spawn)) 35 | .finish() 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | edition = '2021' 3 | name = 'plexo' 4 | version = '0.2.27' 5 | 6 | [dependencies] 7 | async-graphql = { version = "7.0.1", features = [ 8 | "decimal", 9 | "chrono", 10 | "dataloader", 11 | "uuid", 12 | ] } 13 | async-graphql-poem = { version = "7.0.1" } 14 | poem = { version = "2.0.0", features = ["cookie", "static-files"] } 15 | tracing = { version = "0.1.40" } 16 | tracing-subscriber = { version = "0.3.18" } 17 | lazy_static = { version = "1.4.0" } 18 | tokio-stream = "0.1.14" 19 | sqlx = { version = "0.7.3", features = [ 20 | "runtime-tokio-native-tls", 21 | "postgres", 22 | "uuid", 23 | "time", 24 | "json", 25 | ] } 26 | tokio = { version = "1.36.0", features = ["full"] } 27 | dotenvy = "0.15.7" 28 | chrono = "0.4.34" 29 | serde = "1.0.196" 30 | serde_json = "1.0.113" 31 | oauth2 = { version = "4.4.2", features = ["reqwest"] } 32 | reqwest = { version = "0.11.24", features = ["json"] } 33 | jsonwebtoken = "9.2.0" 34 | async-trait = "0.1.77" 35 | percent-encoding = "2.3.1" 36 | mime = "0.3.17" 37 | async-openai = "0.18.3" 38 | cookie = "0.18.0" 39 | thiserror = "1.0.57" 40 | uuid = { version = "1.7.0", features = ["v4", "fast-rng", "macro-diagnostics"] } 41 | argon2 = "0.5.3" 42 | poem-openapi = { version = "4.0.0", features = [ 43 | "swagger-ui", 44 | "chrono", 45 | "uuid", 46 | ] } 47 | 48 | 49 | [workspace] 50 | members = [] 51 | 52 | # [lib] 53 | # proc-macro = true 54 | -------------------------------------------------------------------------------- /src/sdk/labels.rs: -------------------------------------------------------------------------------- 1 | use crate::graphql::auth::extract_context; 2 | use async_graphql::{dataloader::DataLoader, ComplexObject, Context, Result, SimpleObject}; 3 | use chrono::{DateTime, Utc}; 4 | use uuid::Uuid; 5 | 6 | use super::loaders::TaskLoader; 7 | use super::task::Task; 8 | 9 | #[derive(SimpleObject, Clone)] 10 | #[graphql(complex)] 11 | pub struct Label { 12 | pub id: Uuid, 13 | pub created_at: DateTime, 14 | pub updated_at: DateTime, 15 | 16 | pub name: String, 17 | pub description: Option, 18 | pub color: Option, 19 | } 20 | 21 | #[ComplexObject] 22 | impl Label { 23 | pub async fn tasks(&self, ctx: &Context<'_>) -> Result> { 24 | let (plexo_engine, _member_id) = extract_context(ctx)?; 25 | 26 | let loader = ctx.data::>().unwrap(); 27 | 28 | let ids: Vec = sqlx::query!( 29 | r#" 30 | SELECT task_id FROM labels_by_tasks 31 | WHERE label_id = $1 32 | "#, 33 | &self.id 34 | ) 35 | .fetch_all(&*plexo_engine.pool) 36 | .await 37 | .unwrap() 38 | .into_iter() 39 | .map(|id| id.task_id) 40 | .collect(); 41 | 42 | let tasks_map = loader.load_many(ids.clone()).await.unwrap(); 43 | 44 | let tasks: &Vec = &ids 45 | .into_iter() 46 | .map(|id| tasks_map.get(&id).unwrap().clone()) 47 | .collect(); 48 | 49 | Ok(tasks.clone()) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/llm/openai.rs: -------------------------------------------------------------------------------- 1 | use async_openai::{ 2 | config::OpenAIConfig, 3 | types::{ 4 | ChatCompletionRequestSystemMessageArgs, ChatCompletionRequestUserMessageArgs, 5 | CreateChatCompletionRequestArgs, 6 | }, 7 | Client, 8 | }; 9 | 10 | use crate::config::LLM_MODEL_NAME; 11 | 12 | #[derive(Clone)] 13 | pub struct LLMEngine { 14 | client: Client, 15 | } 16 | 17 | impl LLMEngine { 18 | pub fn new() -> Self { 19 | let client = Client::new(); 20 | Self { client } 21 | } 22 | 23 | pub async fn chat_completion(&self, system_message: String, user_message: String) -> String { 24 | let request = CreateChatCompletionRequestArgs::default() 25 | .max_tokens(512u16) 26 | .model(LLM_MODEL_NAME.to_string()) 27 | .messages([ 28 | ChatCompletionRequestSystemMessageArgs::default() 29 | .content(system_message) 30 | .build() 31 | .unwrap() 32 | .into(), 33 | ChatCompletionRequestUserMessageArgs::default() 34 | .content(user_message) 35 | .build() 36 | .unwrap() 37 | .into(), 38 | ]) 39 | .build() 40 | .unwrap(); 41 | 42 | let response = self.client.chat().create(request).await.unwrap(); 43 | 44 | response 45 | .choices 46 | .first() 47 | .unwrap() 48 | .message 49 | .content 50 | .clone() 51 | .unwrap() 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/system/prelude.rs: -------------------------------------------------------------------------------- 1 | use crate::config::{ADMIN_EMAIL, ADMIN_NAME, ADMIN_PASSWORD, ORGANIZATION_NAME}; 2 | 3 | use super::core::Engine; 4 | use async_trait::async_trait; 5 | use sqlx::migrate; 6 | 7 | #[async_trait] 8 | pub trait Prelude { 9 | async fn prelude(&self); 10 | } 11 | 12 | #[async_trait] 13 | impl Prelude for Engine { 14 | async fn prelude(&self) { 15 | match migrate!().run(self.pool.as_ref()).await { 16 | Ok(_) => println!("Database migration successful"), 17 | Err(e) => println!("Database migration failed: {:?}\n", e), 18 | } 19 | 20 | self.set_organization_name(ORGANIZATION_NAME.to_owned()) 21 | .await; 22 | 23 | let admin_email = ADMIN_EMAIL.to_owned(); 24 | 25 | if self 26 | .get_member_by_email(admin_email.clone()) 27 | .await 28 | .is_none() 29 | { 30 | let admin_password = ADMIN_PASSWORD.to_owned(); 31 | let admin_name = ADMIN_NAME.to_owned(); 32 | 33 | let admin_password_hash = self.auth.hash_password(admin_password.as_str()); 34 | 35 | let admin_member = self 36 | .create_member_from_email(admin_email.clone(), admin_name, admin_password_hash) 37 | .await; 38 | 39 | if admin_member.is_none() { 40 | println!("Failed to create admin member"); 41 | } else { 42 | println!( 43 | "Admin created with email: '{}' and password: '{}'", 44 | admin_email, admin_password 45 | ); 46 | } 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /.github/workflows/registry-flavor-demo.yml: -------------------------------------------------------------------------------- 1 | name: "Build demo image and push to private registry" 2 | 3 | on: 4 | push: 5 | branches: 6 | - flavor/demo 7 | 8 | jobs: 9 | build-push-registry: 10 | name: Build image and push to official docker registry 11 | runs-on: ubuntu-latest 12 | # Permissions to use OIDC token authentication 13 | permissions: 14 | contents: read 15 | id-token: write 16 | # Allows pushing to the GitHub Container Registry 17 | packages: write 18 | 19 | steps: 20 | - name: Checkout 21 | uses: actions/checkout@v3 22 | with: 23 | submodules: recursive 24 | 25 | - name: Login to Registry 26 | uses: docker/login-action@v2 27 | with: 28 | registry: docker.io 29 | username: ${{ secrets.DOCKER_USERNAME }} 30 | password: ${{ secrets.DOCKER_PASSWORD }} 31 | 32 | - name: Extracting Cargo Package Version 33 | id: cargo_version 34 | run: | 35 | echo "version=v$(cargo pkgid | cut -d@ -f2 | cut -d: -f2)" >> $GITHUB_OUTPUT 36 | 37 | - name: Docker meta 38 | id: docker_meta 39 | uses: docker/metadata-action@v4 40 | with: 41 | images: minskylab/plexo 42 | flavor: | 43 | latest=false 44 | tags: | 45 | type=raw,value=${{ steps.cargo_version.outputs.version }}-demo,enable=${{ github.ref == 'refs/heads/flavor/demo' }} 46 | 47 | - uses: depot/setup-action@v1 48 | 49 | - name: Build and push 50 | id: docker_build 51 | uses: depot/build-push-action@v1 52 | with: 53 | project: qk8wpgrv4g 54 | push: true 55 | labels: ${{ steps.docker_meta.outputs.labels }} 56 | tags: ${{ steps.docker_meta.outputs.tags }} 57 | -------------------------------------------------------------------------------- /src/config.rs: -------------------------------------------------------------------------------- 1 | use std::env::var; 2 | 3 | use lazy_static::lazy_static; 4 | 5 | lazy_static! { 6 | pub static ref HOST: String = var("HOST").unwrap_or("0.0.0.0".into()); 7 | pub static ref PORT: String = var("PORT").unwrap_or("8080".into()); 8 | pub static ref URL: String = var("URL").unwrap_or(format!("{}:{}", *HOST, *PORT)); 9 | pub static ref SCHEMA: String = var("SCHEMA").unwrap_or("http".into()); 10 | pub static ref DOMAIN: String = var("DOMAIN").unwrap_or(format!("{}://{}", *SCHEMA, *URL)); 11 | // 12 | pub static ref DATABASE_URL: String = var("DATABASE_URL").expect("DATABASE_URL environment variable not set"); 13 | pub static ref GITHUB_CLIENT_ID: Option = var("GITHUB_CLIENT_ID").ok(); 14 | pub static ref GITHUB_CLIENT_SECRET: Option = var("GITHUB_CLIENT_SECRET").ok(); 15 | pub static ref GITHUB_REDIRECT_URL: String = var("GITHUB_REDIRECT_URL").unwrap_or(format!("{}/auth/github/callback", *DOMAIN)); 16 | 17 | pub static ref LLM_MODEL_NAME: String = var("LLM_MODEL_NAME").unwrap_or("gpt-3.5-turbo".into()); 18 | 19 | pub static ref ADMIN_EMAIL: String = var("ADMIN_EMAIL").unwrap_or("admin@plexo.app".into()); 20 | pub static ref ADMIN_PASSWORD: String = var("ADMIN_PASSWORD").unwrap_or("admin".into()); 21 | pub static ref ADMIN_NAME: String = var("ADMIN_NAME").unwrap_or("Admin".into()); 22 | 23 | pub static ref ORGANIZATION_NAME: String = var("ORGANIZATION_NAME").unwrap_or("Plexo".into()); 24 | 25 | pub static ref JWT_ACCESS_TOKEN_SECRET: String = var("JWT_ACCESS_TOKEN_SECRET").unwrap_or("secret".into()); 26 | pub static ref JWT_REFRESH_TOKEN_SECRET: String = var("JWT_REFRESH_TOKEN_SECRET").unwrap_or("secret".into()); 27 | 28 | pub static ref STATIC_PAGE_ENABLED: bool = var("STATIC_PAGE_ENABLED").unwrap_or("false".into()).to_lowercase() == "true"; 29 | } 30 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Install dependencies only when needed 2 | FROM node:16-alpine AS platform-deps 3 | # Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed. 4 | RUN apk add --no-cache libc6-compat 5 | WORKDIR /app 6 | 7 | # Install dependencies based on the preferred package manager 8 | COPY plexo-platform/package.json plexo-platform/yarn.lock ./ 9 | RUN \ 10 | if [ -f yarn.lock ]; then yarn --frozen-lockfile; \ 11 | elif [ -f package-lock.json ]; then npm ci; \ 12 | elif [ -f pnpm-lock.yaml ]; then yarn global add pnpm && pnpm i --frozen-lockfile; \ 13 | else echo "Lockfile not found." && exit 1; \ 14 | fi 15 | 16 | 17 | # Rebuild the source code only when needed 18 | FROM node:16-alpine AS platform-builder 19 | WORKDIR /app 20 | COPY --from=platform-deps /app/node_modules ./node_modules 21 | COPY ./plexo-platform . 22 | 23 | # Next.js collects completely anonymous telemetry data about general usage. 24 | # Learn more here: https://nextjs.org/telemetry 25 | # Uncomment the following line in case you want to disable telemetry during the build. 26 | # ENV NEXT_TELEMETRY_DISABLED 1 27 | 28 | RUN yarn build 29 | 30 | # Start with a rust alpine image 31 | FROM rust:1-alpine3.16 as core-builder 32 | # This is important, see https://github.com/rust-lang/docker-rust/issues/85 33 | ENV RUSTFLAGS="-C target-feature=-crt-static" 34 | # if needed, add additional dependencies here 35 | RUN apk add --no-cache musl-dev 36 | # RUN apk add --no-cache pkgconfig 37 | RUN apk add --no-cache libressl-dev 38 | 39 | # set the workdir and copy the source into it 40 | WORKDIR /app 41 | COPY ./ /app 42 | # do a release build 43 | RUN cargo build --release 44 | RUN strip target/release/plexo 45 | 46 | # use a plain alpine image, the alpine version needs to match the builder 47 | FROM alpine:3.16 as core 48 | # if needed, install additional dependencies here 49 | RUN apk add --no-cache libgcc 50 | RUN apk add --no-cache libressl-dev 51 | 52 | COPY --from=platform-builder /app/out ./plexo-platform/out 53 | # copy the binary into the final image 54 | COPY --from=core-builder /app/target/release/plexo . 55 | # set the binary as entrypoint 56 | ENTRYPOINT ["/plexo"] -------------------------------------------------------------------------------- /src/system/members.rs: -------------------------------------------------------------------------------- 1 | // use crate::sdk::member::MemberRole; 2 | 3 | // pub enum NewMemberPayloadAuthKind { 4 | // Github, 5 | // Google, 6 | // } 7 | 8 | // pub struct NewMemberPayload { 9 | // pub auth_kind: NewMemberPayloadAuthKind, 10 | // pub auth_id: String, 11 | // pub email: String, 12 | 13 | // pub name: String, 14 | 15 | // pub role: Option, 16 | // } 17 | 18 | // impl NewMemberPayload { 19 | // pub fn new( 20 | // auth_kind: NewMemberPayloadAuthKind, 21 | // auth_id: String, 22 | // email: String, 23 | // name: String, 24 | // ) -> Self { 25 | // Self { 26 | // auth_kind, 27 | // auth_id, 28 | // email, 29 | // name, 30 | // role: None, 31 | // } 32 | // } 33 | 34 | // pub fn name(&mut self, name: String) -> &mut Self { 35 | // self.name = name; 36 | // self 37 | // } 38 | 39 | // pub fn role(&mut self, role: MemberRole) -> &mut Self { 40 | // self.role = Some(role); 41 | // self 42 | // } 43 | // } 44 | 45 | // #[derive(Default)] 46 | // pub struct MembersFilter { 47 | // pub name: Option, 48 | // pub email: Option, 49 | // pub role: Option, 50 | // pub github_id: Option, 51 | // pub google_id: Option, 52 | // } 53 | 54 | // impl MembersFilter { 55 | // pub fn new() -> Self { 56 | // Self::default() 57 | // } 58 | 59 | // pub fn set_name(&mut self, name: String) -> &mut Self { 60 | // self.name = Some(name); 61 | // self 62 | // } 63 | 64 | // pub fn set_email(&mut self, email: String) -> &mut Self { 65 | // self.email = Some(email); 66 | // self 67 | // } 68 | 69 | // pub fn set_role(&mut self, role: MemberRole) -> &mut Self { 70 | // self.role = Some(role); 71 | // self 72 | // } 73 | 74 | // pub fn set_github_id(&mut self, github_id: String) -> &mut Self { 75 | // self.github_id = Some(github_id); 76 | // self 77 | // } 78 | 79 | // pub fn set_google_id(&mut self, google_id: String) -> &mut Self { 80 | // self.google_id = Some(google_id); 81 | // self 82 | // } 83 | // } 84 | -------------------------------------------------------------------------------- /.github/workflows/registry-docker.yml: -------------------------------------------------------------------------------- 1 | name: "Build image and push to official docker registry" 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | build-push-registry: 10 | name: Build image and push to official docker registry 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v3 15 | with: 16 | submodules: recursive 17 | 18 | - name: Login to Registry 19 | uses: docker/login-action@v2 20 | with: 21 | registry: docker.io 22 | username: ${{ secrets.DOCKER_USERNAME }} 23 | password: ${{ secrets.DOCKER_PASSWORD }} 24 | 25 | - name: Extracting Cargo Package Version 26 | id: cargo_version 27 | run: | 28 | echo "version=v$(cargo pkgid | cut -d@ -f2 | cut -d: -f2)" >> $GITHUB_OUTPUT 29 | 30 | - name: Docker meta 31 | id: docker_meta 32 | uses: docker/metadata-action@v4 33 | with: 34 | images: minskylab/plexo 35 | flavor: | 36 | latest=true 37 | tags: | 38 | type=sha,format=long,prefix=sha- 39 | type=raw,value=staging,enable=${{ github.ref == 'refs/heads/dev' }} 40 | type=raw,value=stable,enable=${{ github.ref == 'refs/heads/main' }} 41 | type=raw,value=${{ steps.cargo_version.outputs.version }},enable=${{ github.ref == 'refs/heads/main' }} 42 | 43 | - name: Build and push 44 | id: docker_build 45 | uses: docker/build-push-action@v4 46 | with: 47 | # cache-from: type=gha 48 | # cache-to: type=gha,mode=max 49 | push: true 50 | labels: ${{ steps.docker_meta.outputs.labels }} 51 | tags: ${{ steps.docker_meta.outputs.tags }} 52 | 53 | - name: Telegram Notification 54 | uses: appleboy/telegram-action@master 55 | with: 56 | to: ${{ secrets.TELEGRAM_TO }} 57 | token: ${{ secrets.TELEGRAM_TOKEN }} 58 | message: | 59 | New image pushed to docker registry 60 | 61 | Docker Tags: ${{ steps.docker_meta.outputs.tags }} 62 | Commit message: ${{ github.event.commits[0].message }} 63 | 64 | See changes: https://github.com/${{ github.repository }}/commit/${{github.sha}} 65 | -------------------------------------------------------------------------------- /src/graphql/queries/ai_functions.rs: -------------------------------------------------------------------------------- 1 | use async_graphql::{Context, Object, Result}; 2 | 3 | use crate::{ 4 | graphql::auth::extract_context, 5 | llm::suggestions::{TaskSuggestionInput, TaskSuggestionResult}, 6 | }; 7 | 8 | #[derive(Default)] 9 | pub struct AIFunctionsQuery; 10 | 11 | #[Object] 12 | impl AIFunctionsQuery { 13 | async fn suggest_new_task( 14 | &self, 15 | ctx: &Context<'_>, 16 | task: TaskSuggestionInput, 17 | ) -> Result { 18 | let (plexo_engine, _member_id) = extract_context(ctx)?; 19 | 20 | let Ok(raw_suggestion) = plexo_engine 21 | .auto_suggestions_engine 22 | .get_suggestions(task, None) 23 | .await 24 | else { 25 | return Err("Failed to get suggestions".into()); 26 | }; 27 | 28 | Ok(raw_suggestion) 29 | } 30 | 31 | async fn subdivide_task( 32 | &self, 33 | ctx: &Context<'_>, 34 | task_id: String, 35 | #[graphql(default = 5)] subtasks: u32, 36 | ) -> Result> { 37 | let (plexo_engine, _member_id) = extract_context(ctx)?; 38 | 39 | let task_id = task_id.parse::()?; 40 | 41 | let suggestions = plexo_engine 42 | .auto_suggestions_engine 43 | .subdivide_task(task_id, subtasks) 44 | .await 45 | .unwrap(); 46 | 47 | // let raw_suggestion = plexo_engine 48 | // .auto_suggestions_engine 49 | // .get_suggestions(TaskSuggestion { 50 | // title: task.title, 51 | // description: task.description, 52 | // status: task.status, 53 | // priority: task.priority, 54 | // due_date: task.due_date, 55 | // }) 56 | // .await; 57 | // let new_task = plexo_engine 58 | // .task_engine 59 | // .create_task( 60 | // task.title, 61 | // task.description, 62 | // task.status, 63 | // task.priority, 64 | // task.due_date, 65 | // ) 66 | // .await?; 67 | 68 | // plexo_engine 69 | // .task_engine 70 | // .add_subtask(task_id, new_task.id) 71 | // .await?; 72 | 73 | // Ok(task_id.to_string()) 74 | Ok(suggestions) 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/graphql/mutations/auth.rs: -------------------------------------------------------------------------------- 1 | use async_graphql::{Context, Object, Result, SimpleObject}; 2 | 3 | use crate::{ 4 | errors::definitions::PlexoAppError, graphql::auth::extract_context, system::core::Engine, 5 | }; 6 | 7 | #[derive(Default)] 8 | pub struct AuthMutation; 9 | 10 | #[derive(SimpleObject)] 11 | struct LoginResponse { 12 | token: String, 13 | member_id: String, 14 | } 15 | 16 | #[Object] 17 | impl AuthMutation { 18 | async fn login( 19 | &self, 20 | ctx: &Context<'_>, 21 | email: String, 22 | password: String, 23 | ) -> Result { 24 | let plexo_engine = ctx.data::()?.to_owned(); 25 | 26 | let Some(member) = plexo_engine.get_member_by_email(email.clone()).await else { 27 | return Err(PlexoAppError::EmailNotFound.into()); 28 | }; 29 | 30 | let Some(password_hash) = member.password_hash.clone() else { 31 | return Err(PlexoAppError::InvalidPassword.into()); 32 | }; 33 | 34 | if !plexo_engine 35 | .auth 36 | .validate_password(password.as_str(), password_hash.as_str()) 37 | { 38 | return Err(PlexoAppError::InvalidPassword.into()); 39 | }; 40 | 41 | let Ok(session_token) = plexo_engine.auth.jwt_engine.create_session_token(&member) else { 42 | return Err(PlexoAppError::InvalidPassword.into()); 43 | }; 44 | 45 | Ok(LoginResponse { 46 | token: session_token, 47 | member_id: member.id.to_string(), 48 | }) 49 | } 50 | 51 | async fn register( 52 | &self, 53 | ctx: &Context<'_>, 54 | email: String, 55 | name: String, 56 | password: String, 57 | ) -> Result { 58 | let (plexo_engine, _member_id) = extract_context(ctx)?; 59 | 60 | if (plexo_engine.get_member_by_email(email.clone()).await).is_some() { 61 | return Err(PlexoAppError::EmailAlreadyExists.into()); 62 | }; 63 | 64 | let password_hash = plexo_engine.auth.hash_password(password.as_str()); 65 | 66 | let Some(member) = plexo_engine 67 | .create_member_from_email(email.clone(), name.clone(), password_hash) 68 | .await 69 | else { 70 | return Err(PlexoAppError::EmailAlreadyExists.into()); 71 | }; 72 | 73 | let Ok(session_token) = plexo_engine.auth.jwt_engine.create_session_token(&member) else { 74 | return Err(PlexoAppError::InvalidPassword.into()); 75 | }; 76 | 77 | Ok(LoginResponse { 78 | token: session_token, 79 | member_id: member.id.to_string(), 80 | }) 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/handlers.rs: -------------------------------------------------------------------------------- 1 | use async_graphql::{ 2 | http::{GraphiQLSource, ALL_WEBSOCKET_PROTOCOLS}, 3 | Data, Schema, 4 | }; 5 | 6 | use async_graphql_poem::{GraphQLProtocol, GraphQLRequest, GraphQLResponse, GraphQLWebSocket}; 7 | use serde_json::Value; 8 | 9 | use crate::{ 10 | commons::authorization::{get_token_from_cookie, get_token_from_headers}, 11 | config::DOMAIN, 12 | graphql::{mutations::MutationRoot, queries::QueryRoot, subscription::SubscriptionRoot}, 13 | }; 14 | 15 | use poem::{ 16 | handler, 17 | http::HeaderMap, 18 | web::Html, 19 | web::{websocket::WebSocket, Data as PoemData}, 20 | IntoResponse, 21 | }; 22 | 23 | #[handler] 24 | pub async fn graphiq_handler() -> impl IntoResponse { 25 | Html( 26 | GraphiQLSource::build() 27 | .endpoint(format!("{}/graphql", *DOMAIN).as_str()) 28 | .subscription_endpoint(format!("{}/graphql/ws", DOMAIN.replace("http", "ws")).as_str()) 29 | .finish(), 30 | ) 31 | } 32 | 33 | #[handler] 34 | pub async fn index_handler( 35 | schema: PoemData<&Schema>, 36 | headers: &HeaderMap, 37 | req: GraphQLRequest, 38 | ) -> GraphQLResponse { 39 | let mut req = req.0; 40 | // let mut with_token = false; 41 | 42 | if let Some(token) = get_token_from_headers(headers) { 43 | req = req.data(token); 44 | // with_token = true; 45 | } 46 | 47 | if let Some(token) = get_token_from_cookie(headers) { 48 | req = req.data(token); 49 | // with_token = true; 50 | } 51 | 52 | schema.execute(req).await.into() 53 | } 54 | 55 | #[handler] 56 | pub async fn ws_switch_handler( 57 | schema: PoemData<&Schema>, 58 | protocol: GraphQLProtocol, 59 | websocket: WebSocket, 60 | ) -> impl IntoResponse { 61 | let schema = schema.0.clone(); 62 | websocket 63 | .protocols(ALL_WEBSOCKET_PROTOCOLS) 64 | .on_upgrade(move |stream| { 65 | GraphQLWebSocket::new(stream, schema, protocol) 66 | .on_connection_init(on_connection_init) 67 | .serve() 68 | }) 69 | } 70 | 71 | pub async fn on_connection_init(value: Value) -> async_graphql::Result { 72 | match &value { 73 | Value::Object(map) => { 74 | if let Some(Value::String(token)) = map.get("Authorization") { 75 | let mut data = Data::default(); 76 | data.insert(token.to_string()); 77 | Ok(data) 78 | } else { 79 | Err("Authorization token is required".into()) 80 | } 81 | } 82 | _ => Err("Authorization token is required".into()), 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | use dotenvy::dotenv; 2 | use plexo::{ 3 | auth::{ 4 | core::{ 5 | email_basic_login_handler, github_callback_handler, github_sign_in_handler, 6 | logout_handler, 7 | }, 8 | engine::AuthEngine, 9 | }, 10 | config::{ 11 | DATABASE_URL, DOMAIN, GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET, GITHUB_REDIRECT_URL, 12 | JWT_ACCESS_TOKEN_SECRET, STATIC_PAGE_ENABLED, URL, 13 | }, 14 | handlers::{graphiq_handler, index_handler, ws_switch_handler}, 15 | openapi::api::Api, 16 | statics::StaticServer, 17 | system::{core::Engine, prelude::Prelude, schema::GraphQLSchema}, 18 | }; 19 | use poem::{get, listener::TcpListener, middleware::Cors, post, EndpointExt, Route, Server}; 20 | use poem_openapi::OpenApiService; 21 | use sqlx::postgres::PgPoolOptions; 22 | 23 | #[tokio::main] 24 | async fn main() { 25 | dotenv().ok(); 26 | 27 | let plexo_engine = Engine::new( 28 | PgPoolOptions::new() 29 | .max_connections(3) 30 | .connect(&DATABASE_URL) 31 | .await 32 | .unwrap(), 33 | AuthEngine::new( 34 | // TODO: That's horrible, fix it 35 | (*JWT_ACCESS_TOKEN_SECRET).to_string(), 36 | (*JWT_ACCESS_TOKEN_SECRET).to_string(), 37 | (*GITHUB_CLIENT_ID).to_owned(), 38 | (*GITHUB_CLIENT_SECRET).to_owned(), 39 | Some((*GITHUB_REDIRECT_URL).to_owned()), 40 | ), 41 | ); 42 | 43 | plexo_engine.prelude().await; 44 | 45 | let schema = plexo_engine.graphql_api_schema(); 46 | 47 | let api_service = OpenApiService::new(Api::default(), "Hello World", "1.0") 48 | .server("http://localhost:3000/api"); 49 | let ui = api_service.swagger_ui(); 50 | 51 | let spec = api_service.spec(); 52 | 53 | std::fs::write("openapi.json", spec).unwrap(); 54 | 55 | let mut app = Route::new() 56 | .nest("/api", api_service) 57 | .nest("/", ui) 58 | // .nest("/", static_page) 59 | // Non authenticated routes 60 | .at("/auth/email/login", post(email_basic_login_handler)) 61 | // .at("/auth/email/register", post(email_basic_register_handler)) 62 | // 63 | .at("/auth/github", get(github_sign_in_handler)) 64 | .at("/auth/github/callback", get(github_callback_handler)) 65 | // 66 | .at("/auth/logout", get(logout_handler)) 67 | // 68 | .at("/playground", get(graphiq_handler)) 69 | .at("/graphql", post(index_handler)) 70 | .at("/graphql/ws", get(ws_switch_handler)); 71 | 72 | if *STATIC_PAGE_ENABLED { 73 | let static_page_root_path = "plexo-platform/out".to_string(); 74 | 75 | let static_page = 76 | StaticServer::new(static_page_root_path, plexo_engine.clone()).index_file("index.html"); 77 | 78 | app = app.nest("/", static_page); 79 | 80 | println!("Static page enabled"); 81 | } 82 | 83 | let app = app 84 | .with( 85 | Cors::new().allow_credentials(true), // .expose_header("Set-Cookie"), 86 | ) 87 | .data(schema) 88 | .data(plexo_engine.clone()); 89 | 90 | println!("Visit GraphQL Playground at {}/playground", *DOMAIN); 91 | 92 | Server::new(TcpListener::bind(URL.to_owned())) 93 | .run(app) 94 | .await 95 | .expect("Fail to start web server"); 96 | } 97 | -------------------------------------------------------------------------------- /src/sdk/activity.rs: -------------------------------------------------------------------------------- 1 | use async_graphql::{dataloader::DataLoader, ComplexObject, Context, Enum, Result, SimpleObject}; 2 | use chrono::{DateTime, Utc}; 3 | use uuid::Uuid; 4 | 5 | use std::str::FromStr; 6 | 7 | use super::loaders::MemberLoader; 8 | use super::member::Member; 9 | use crate::graphql::auth::extract_context; 10 | 11 | #[derive(SimpleObject, Clone, Debug)] 12 | #[graphql(complex)] 13 | pub struct Activity { 14 | pub id: Uuid, 15 | pub created_at: DateTime, 16 | pub updated_at: DateTime, 17 | 18 | pub member_id: Uuid, 19 | pub resource_id: Uuid, 20 | 21 | pub operation: ActivityOperationType, 22 | pub resource_type: ActivityResourceType, 23 | } 24 | 25 | #[ComplexObject] 26 | impl Activity { 27 | pub async fn member(&self, ctx: &Context<'_>) -> Result { 28 | let (_plexo_engine, _member_id) = extract_context(ctx)?; 29 | 30 | let loader = ctx.data::>()?; 31 | 32 | Ok(loader.load_one(self.member_id).await?.unwrap()) 33 | } 34 | } 35 | 36 | #[derive(Enum, Copy, Clone, Eq, PartialEq, Debug)] 37 | pub enum ActivityOperationType { 38 | Create, 39 | Update, 40 | Delete, 41 | } 42 | 43 | impl ToString for ActivityOperationType { 44 | fn to_string(&self) -> String { 45 | match self { 46 | ActivityOperationType::Create => "Create".to_string(), 47 | ActivityOperationType::Update => "Update".to_string(), 48 | ActivityOperationType::Delete => "Delete".to_string(), 49 | } 50 | } 51 | } 52 | 53 | impl FromStr for ActivityOperationType { 54 | type Err = (); 55 | 56 | fn from_str(s: &str) -> Result { 57 | match s { 58 | "Create" => Ok(ActivityOperationType::Create), 59 | "Update" => Ok(ActivityOperationType::Update), 60 | "Delete" => Ok(ActivityOperationType::Delete), 61 | _ => Err(()), 62 | } 63 | } 64 | } 65 | 66 | #[derive(Enum, Copy, Clone, Eq, PartialEq, Debug)] 67 | pub enum ActivityResourceType { 68 | Task, 69 | Project, 70 | Team, 71 | Member, 72 | Label, 73 | Organization, 74 | } 75 | 76 | impl ToString for ActivityResourceType { 77 | fn to_string(&self) -> String { 78 | match self { 79 | ActivityResourceType::Task => "Task".to_string(), 80 | ActivityResourceType::Project => "Project".to_string(), 81 | ActivityResourceType::Team => "Team".to_string(), 82 | ActivityResourceType::Member => "Member".to_string(), 83 | ActivityResourceType::Label => "Label".to_string(), 84 | ActivityResourceType::Organization => "Organization".to_string(), 85 | } 86 | } 87 | } 88 | 89 | impl FromStr for ActivityResourceType { 90 | type Err = (); 91 | 92 | fn from_str(s: &str) -> Result { 93 | match s { 94 | "Task" => Ok(ActivityResourceType::Task), 95 | "Project" => Ok(ActivityResourceType::Project), 96 | "Team" => Ok(ActivityResourceType::Team), 97 | "Member" => Ok(ActivityResourceType::Member), 98 | "Label" => Ok(ActivityResourceType::Label), 99 | "Organization" => Ok(ActivityResourceType::Organization), 100 | _ => Err(()), 101 | } 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/auth/jwt.rs: -------------------------------------------------------------------------------- 1 | use chrono::Utc; 2 | use jsonwebtoken::{decode, encode, errors::Error, DecodingKey, EncodingKey, Header}; 3 | use serde::{Deserialize, Serialize}; 4 | use uuid::Uuid; 5 | 6 | use crate::sdk::member::Member; 7 | 8 | #[derive(Clone)] 9 | pub struct JWTEngine { 10 | access_token_secret: String, 11 | // refresh_token_secret: String, 12 | } 13 | 14 | #[derive(Debug, Serialize, Deserialize)] 15 | pub struct PlexoAuthTokenClaims { 16 | iss: String, 17 | aud: String, 18 | sub: String, 19 | exp: usize, 20 | } 21 | 22 | impl PlexoAuthTokenClaims { 23 | pub fn member_id(&self) -> Uuid { 24 | Uuid::parse_str(&self.sub).unwrap() 25 | } 26 | } 27 | 28 | impl JWTEngine { 29 | pub fn new(access_token_secret: String, _refresh_token_secret: String) -> Self { 30 | Self { 31 | access_token_secret, 32 | // refresh_token_secret, 33 | } 34 | } 35 | 36 | pub fn create_session_token(&self, member: &Member) -> Result { 37 | let claims = PlexoAuthTokenClaims { 38 | iss: "Plexo".to_string(), 39 | aud: "session.plexo.app".to_string(), 40 | sub: member.id.to_string(), 41 | exp: (Utc::now() + chrono::Duration::days(7)).timestamp() as usize, 42 | }; 43 | 44 | let token = encode( 45 | &Header::default(), 46 | &claims, 47 | &EncodingKey::from_secret(self.access_token_secret.as_ref()), 48 | )?; 49 | 50 | Ok(token) 51 | } 52 | 53 | pub fn decode_session_token(&self, token: &str) -> Result { 54 | let token_data = decode::( 55 | token, 56 | &DecodingKey::from_secret(self.access_token_secret.as_ref()), 57 | &jsonwebtoken::Validation::default(), 58 | )?; 59 | 60 | Ok(token_data.claims) 61 | } 62 | 63 | // pub fn decode_access_token(&self, token: &str) -> Result { 64 | // let token_data = decode::( 65 | // token, 66 | // &DecodingKey::from_secret(self.access_token_secret.as_ref()), 67 | // &jsonwebtoken::Validation::default(), 68 | // )?; 69 | 70 | // Ok(token_data.claims) 71 | // } 72 | 73 | // pub fn decode_refresh_token(&self, token: &str) -> Result { 74 | // let token_data = decode::( 75 | // token, 76 | // &DecodingKey::from_secret(self.refresh_token_secret.as_ref()), 77 | // &jsonwebtoken::Validation::default(), 78 | // )?; 79 | 80 | // Ok(token_data.claims) 81 | // } 82 | 83 | // pub fn refresh_access_token( 84 | // &self, 85 | // access_token: &str, 86 | // refresh_token: &str, 87 | // ) -> Result { 88 | // let mut claims_access_token = self.decode_access_token(access_token)?; 89 | // let _claims_refresh_token = self.decode_refresh_token(refresh_token)?; 90 | 91 | // claims_access_token.exp += 1000; // TODO 92 | 93 | // let token = encode( 94 | // &Header::default(), 95 | // &claims_access_token, 96 | // &EncodingKey::from_secret(self.access_token_secret.as_ref()), 97 | // )?; 98 | 99 | // Ok(token) 100 | // } 101 | } 102 | -------------------------------------------------------------------------------- /src/sdk/team.rs: -------------------------------------------------------------------------------- 1 | use std::str::FromStr; 2 | 3 | use async_graphql::{ComplexObject, Context, Enum, Result, SimpleObject}; 4 | use chrono::{DateTime, Utc}; 5 | 6 | use crate::{ 7 | graphql::auth::extract_context, 8 | sdk::{member::Member, project::Project}, 9 | }; 10 | use async_graphql::dataloader::DataLoader; 11 | use uuid::Uuid; 12 | 13 | use super::loaders::{MemberLoader, ProjectLoader}; 14 | 15 | #[derive(SimpleObject, Clone, Debug)] 16 | #[graphql(complex)] 17 | pub struct Team { 18 | pub id: Uuid, 19 | pub created_at: DateTime, 20 | pub updated_at: DateTime, 21 | 22 | pub name: String, 23 | 24 | pub owner_id: Uuid, 25 | 26 | pub visibility: TeamVisibility, 27 | 28 | pub prefix: Option, 29 | } 30 | 31 | #[ComplexObject] 32 | impl Team { 33 | pub async fn owner(&self, ctx: &Context<'_>) -> Result> { 34 | let loader = ctx.data::>().unwrap(); 35 | 36 | //match to see is project_id is none 37 | Ok(loader.load_one(self.owner_id).await.unwrap()) 38 | } 39 | 40 | pub async fn members(&self, ctx: &Context<'_>) -> Result> { 41 | let (plexo_engine, _member_id) = extract_context(ctx)?; 42 | 43 | let loader = ctx.data::>().unwrap(); 44 | 45 | let ids: Vec = sqlx::query!( 46 | r#" 47 | SELECT member_id FROM members_by_teams 48 | WHERE team_id = $1 49 | "#, 50 | &self.id 51 | ) 52 | .fetch_all(&*plexo_engine.pool) 53 | .await 54 | .unwrap() 55 | .into_iter() 56 | .map(|id| id.member_id) 57 | .collect(); 58 | 59 | let members_map = loader.load_many(ids.clone()).await.unwrap(); 60 | 61 | let members: &Vec = &ids 62 | .into_iter() 63 | .map(|id| members_map.get(&id).unwrap().clone()) 64 | .collect(); 65 | 66 | Ok(members.clone()) 67 | } 68 | 69 | pub async fn projects(&self, ctx: &Context<'_>) -> Result> { 70 | let (plexo_engine, _member_id) = extract_context(ctx)?; 71 | 72 | let loader = ctx.data::>().unwrap(); 73 | 74 | let ids: Vec = sqlx::query!( 75 | r#" 76 | SELECT project_id FROM teams_by_projects 77 | WHERE team_id = $1 78 | "#, 79 | &self.id 80 | ) 81 | .fetch_all(&*plexo_engine.pool) 82 | .await 83 | .unwrap() 84 | .into_iter() 85 | .map(|id| id.project_id) 86 | .collect(); 87 | 88 | let projects_map = loader.load_many(ids.clone()).await.unwrap(); 89 | 90 | let projects: &Vec = &ids 91 | .into_iter() 92 | .map(|id| projects_map.get(&id).unwrap().clone()) 93 | .collect(); 94 | 95 | Ok(projects.clone()) 96 | } 97 | } 98 | 99 | #[derive(Enum, Copy, Clone, Eq, PartialEq, Debug)] 100 | pub enum TeamVisibility { 101 | None, 102 | Public, 103 | Private, 104 | Internal, 105 | } 106 | 107 | impl TeamVisibility { 108 | pub fn from_optional_str(s: &Option) -> Self { 109 | match s { 110 | Some(s) => Self::from_str(s.as_str()).unwrap_or(Self::None), 111 | None => Self::None, 112 | } 113 | } 114 | 115 | pub fn to_str(&self) -> &'static str { 116 | match self { 117 | Self::None => "None", 118 | Self::Public => "Public", 119 | Self::Private => "Private", 120 | Self::Internal => "Internal", 121 | } 122 | } 123 | } 124 | 125 | impl FromStr for TeamVisibility { 126 | type Err = (); 127 | 128 | fn from_str(s: &str) -> Result { 129 | match s { 130 | "None" => Ok(Self::None), 131 | "Public" => Ok(Self::Public), 132 | "Private" => Ok(Self::Private), 133 | "Internal" => Ok(Self::Internal), 134 | _ => Err(()), 135 | } 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /src/sdk/project.rs: -------------------------------------------------------------------------------- 1 | use async_graphql::{ComplexObject, Context, Result, SimpleObject}; 2 | use chrono::{DateTime, Utc}; 3 | use uuid::Uuid; 4 | 5 | use async_graphql::dataloader::DataLoader; 6 | use poem_openapi::Object; 7 | 8 | use super::loaders::{MemberLoader, TeamLoader}; 9 | use crate::{ 10 | graphql::auth::extract_context, 11 | sdk::{ 12 | member::Member, 13 | task::{Task, TaskPriority, TaskStatus}, 14 | team::Team, 15 | utilities::DateTimeBridge, 16 | }, 17 | }; 18 | 19 | #[derive(SimpleObject, Object, Clone)] 20 | #[graphql(complex)] 21 | pub struct Project { 22 | pub id: Uuid, 23 | pub created_at: DateTime, 24 | pub updated_at: DateTime, 25 | 26 | pub name: String, 27 | pub prefix: Option, 28 | 29 | pub owner_id: Uuid, 30 | pub description: Option, 31 | 32 | pub lead_id: Option, 33 | pub start_date: Option>, 34 | pub due_date: Option>, 35 | } 36 | 37 | #[ComplexObject] 38 | impl Project { 39 | pub async fn owner(&self, ctx: &Context<'_>) -> Result> { 40 | let (_plexo_engine, _member_id) = extract_context(ctx)?; 41 | 42 | let loader = ctx.data::>().unwrap(); 43 | 44 | Ok(loader.load_one(self.owner_id).await.unwrap()) 45 | } 46 | 47 | pub async fn members(&self, ctx: &Context<'_>) -> Result> { 48 | let (plexo_engine, _member_id) = extract_context(ctx)?; 49 | 50 | let loader = ctx.data::>().unwrap(); 51 | 52 | let ids: Vec = sqlx::query!( 53 | r#" 54 | SELECT member_id FROM members_by_projects 55 | WHERE project_id = $1 56 | "#, 57 | &self.id 58 | ) 59 | .fetch_all(&*plexo_engine.pool) 60 | .await 61 | .unwrap() 62 | .into_iter() 63 | .map(|id| id.member_id) 64 | .collect(); 65 | 66 | let members_map = loader.load_many(ids.clone()).await.unwrap(); 67 | 68 | let members: &Vec = &ids 69 | .into_iter() 70 | .map(|id| members_map.get(&id).unwrap().clone()) 71 | .collect(); 72 | 73 | Ok(members.clone()) 74 | } 75 | 76 | pub async fn tasks(&self, ctx: &Context<'_>) -> Result> { 77 | //este caso específico necesita revisión 78 | let (plexo_engine, _member_id) = extract_context(ctx)?; 79 | 80 | let tasks = sqlx::query!( 81 | r#" 82 | SELECT * FROM tasks 83 | WHERE project_id = $1"#, 84 | &self.id 85 | ) 86 | .fetch_all(&*plexo_engine.pool) 87 | .await 88 | .unwrap(); 89 | 90 | Ok(tasks 91 | .iter() 92 | .map(|r| Task { 93 | id: r.id, 94 | created_at: DateTimeBridge::from_offset_date_time(r.created_at), 95 | updated_at: DateTimeBridge::from_offset_date_time(r.updated_at), 96 | title: r.title.clone(), 97 | description: r.description.clone(), 98 | status: TaskStatus::from_optional_str(&r.status), 99 | priority: TaskPriority::from_optional_str(&r.priority), 100 | due_date: r.due_date.map(DateTimeBridge::from_offset_date_time), 101 | project_id: r.project_id, 102 | lead_id: r.lead_id, 103 | owner_id: r.owner_id, 104 | count: r.count, 105 | parent_id: r.parent_id, 106 | }) 107 | .collect()) 108 | } 109 | 110 | pub async fn teams(&self, ctx: &Context<'_>) -> Result> { 111 | let (plexo_engine, _member_id) = extract_context(ctx)?; 112 | 113 | let loader = ctx.data::>().unwrap(); 114 | 115 | let ids: Vec = sqlx::query!( 116 | r#" 117 | SELECT team_id FROM teams_by_projects 118 | WHERE project_id = $1 119 | "#, 120 | &self.id 121 | ) 122 | .fetch_all(&*plexo_engine.pool) 123 | .await 124 | .unwrap() 125 | .into_iter() 126 | .map(|id| id.team_id) 127 | .collect(); 128 | 129 | let teams_map = loader.load_many(ids.clone()).await.unwrap(); 130 | 131 | let teams: &Vec = &ids 132 | .into_iter() 133 | .map(|id| teams_map.get(&id).unwrap().clone()) 134 | .collect(); 135 | 136 | Ok(teams.clone()) 137 | } 138 | 139 | pub async fn leader(&self, ctx: &Context<'_>) -> Option { 140 | let loader = ctx.data::>().unwrap(); 141 | 142 | //match to see is project_id is none 143 | match self.lead_id { 144 | Some(lead_id) => loader.load_one(lead_id).await.unwrap(), 145 | None => None, 146 | } 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /src/system/subscriptions.rs: -------------------------------------------------------------------------------- 1 | use crate::sdk::project::Project; 2 | use crate::sdk::task::Task; 3 | use crate::sdk::team::Team; 4 | use std::collections::HashMap; 5 | use std::error::Error; 6 | use std::sync::Arc; 7 | use tokio::sync::mpsc::Sender; 8 | use tokio::sync::Mutex; 9 | use uuid::Uuid; 10 | 11 | #[derive(Clone)] 12 | pub enum DataContainer { 13 | TaskContainer(Task), 14 | ProjectContainer(Project), 15 | TeamContainer(Team), 16 | } 17 | pub struct Subscription { 18 | // id: String, 19 | sender: Sender, 20 | } 21 | 22 | impl Subscription { 23 | fn new(_id: String, sender: Sender) -> Self { 24 | Subscription { sender } 25 | } 26 | } 27 | 28 | type MyResult = std::result::Result; 29 | 30 | #[derive(Clone)] 31 | pub struct SubscriptionManager { 32 | pub subscriptions: Arc>>, 33 | pub id_task: String, 34 | pub id_project: String, 35 | pub id_team: String, 36 | } 37 | 38 | impl Default for SubscriptionManager { 39 | fn default() -> Self { 40 | Self::new() 41 | } 42 | } 43 | 44 | impl SubscriptionManager { 45 | pub fn new() -> Self { 46 | Self { 47 | subscriptions: Arc::new(Mutex::new(HashMap::new())), 48 | id_task: Uuid::new_v4().to_string(), 49 | id_project: Uuid::new_v4().to_string(), 50 | id_team: Uuid::new_v4().to_string(), 51 | } 52 | } 53 | 54 | pub async fn add_subscription( 55 | &self, 56 | sender: Sender, 57 | option: i32, 58 | ) -> MyResult { 59 | let mut subscriptions = self.subscriptions.lock().await; 60 | 61 | if option == 1 { 62 | if subscriptions.contains_key(&self.id_task) { 63 | return Err(Box::::from(format!( 64 | "Subscription with id '{}' already exists", 65 | self.id_task 66 | )) 67 | .to_string()); 68 | } 69 | 70 | subscriptions.insert( 71 | self.id_task.clone(), 72 | Subscription::new(self.id_task.clone(), sender), 73 | ); 74 | Ok(self.id_task.clone()) 75 | } else if option == 2 { 76 | if subscriptions.contains_key(&self.id_project) { 77 | return Err(Box::::from(format!( 78 | "Subscription with id '{}' already exists", 79 | self.id_project 80 | )) 81 | .to_string()); 82 | } 83 | 84 | subscriptions.insert( 85 | self.id_project.clone(), 86 | Subscription::new(self.id_project.clone(), sender), 87 | ); 88 | Ok(self.id_project.clone()) 89 | } else { 90 | if subscriptions.contains_key(&self.id_team) { 91 | return Err(Box::::from(format!( 92 | "Subscription with id '{}' already exists", 93 | self.id_team 94 | )) 95 | .to_string()); 96 | } 97 | 98 | subscriptions.insert( 99 | self.id_team.clone(), 100 | Subscription::new(self.id_team.clone(), sender), 101 | ); 102 | Ok(self.id_team.clone()) 103 | } 104 | } 105 | 106 | async fn _remove_subscription(&self, id: String) -> MyResult { 107 | let mut subscriptions = self.subscriptions.lock().await; 108 | 109 | if !subscriptions.contains_key(&id) { 110 | return Ok(false); 111 | } 112 | 113 | subscriptions.remove(&id); 114 | Ok(true) 115 | } 116 | 117 | pub async fn send_task_event(&self, event: Task) -> MyResult { 118 | let mut subscriptions = self.subscriptions.lock().await; 119 | 120 | if let Some(subscription) = subscriptions.get_mut(&self.id_task) { 121 | subscription 122 | .sender 123 | .clone() 124 | .try_send(DataContainer::TaskContainer(event.clone())) 125 | .expect("Fallo al enviar el evento"); 126 | } 127 | Ok(event) 128 | } 129 | 130 | pub async fn send_project_event(&self, event: Project) -> MyResult { 131 | let mut subscriptions = self.subscriptions.lock().await; 132 | 133 | if let Some(subscription) = subscriptions.get_mut(&self.id_project) { 134 | subscription 135 | .sender 136 | .clone() 137 | .try_send(DataContainer::ProjectContainer(event.clone())) 138 | .expect("Fallo al enviar el evento"); 139 | } 140 | 141 | Ok(event) 142 | } 143 | 144 | pub async fn send_team_event(&self, event: Team) -> MyResult { 145 | let mut subscriptions = self.subscriptions.lock().await; 146 | 147 | if let Some(subscription) = subscriptions.get_mut(&self.id_team) { 148 | subscription 149 | .sender 150 | .clone() 151 | .try_send(DataContainer::TeamContainer(event.clone())) 152 | .expect("Fallo al enviar el evento"); 153 | } 154 | 155 | Ok(event) 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /src/openapi/api.rs: -------------------------------------------------------------------------------- 1 | // use poem::{listener::TcpListener, Route}; 2 | use crate::sdk::project::Project; 3 | use poem_openapi::param::Path; 4 | use poem_openapi::payload::Json; 5 | use poem_openapi::{ApiResponse, OpenApi, Tags}; 6 | use tokio::sync::Mutex; 7 | 8 | use crate::sdk::task::Task; 9 | 10 | #[derive(Tags)] 11 | enum ApiTags { 12 | /// Operations about tasks 13 | Task, 14 | /// Operations about members 15 | // Member, 16 | /// Operations about projects 17 | Project, 18 | // /// Operations about teams 19 | // Team, 20 | } 21 | 22 | #[derive(Default)] 23 | pub struct Api { 24 | pub tasks: Mutex>, 25 | } 26 | 27 | #[OpenApi] 28 | impl Api { 29 | // #[oai(path = "/hello", method = "get", operation_id = "hello")] 30 | // async fn index(&self, name: Query>) -> PlainText { 31 | // match name.0 { 32 | // Some(name) => PlainText(format!("hello, {}!", name)), 33 | // None => PlainText("hello!".to_string()), 34 | // } 35 | // } 36 | 37 | #[oai( 38 | path = "/tasks", 39 | method = "post", 40 | tag = "ApiTags::Task", 41 | operation_id = "create_task" 42 | )] 43 | async fn create_task(&self, task: Json) -> CreateTaskResponse { 44 | let mut users = self.tasks.lock().await; 45 | users.insert(0, task.0.clone()); 46 | 47 | CreateTaskResponse::Ok(Json(task.0)) 48 | } 49 | 50 | #[oai( 51 | path = "/tasks", 52 | method = "get", 53 | tag = "ApiTags::Task", 54 | operation_id = "list_tasks" 55 | )] 56 | async fn list_tasks(&self) -> ListTasksResponse { 57 | let users = self.tasks.lock().await; 58 | ListTasksResponse::Ok(Json(users.clone())) 59 | } 60 | 61 | #[oai( 62 | path = "/tasks/:id", 63 | method = "get", 64 | tag = "ApiTags::Task", 65 | operation_id = "get_task" 66 | )] 67 | async fn get_task(&self, _id: Path) -> GetTaskResponse { 68 | // let users = self.tasks.lock().await; 69 | // let task = users.iter().find(|task| task.id == Uuid::from_str(id.0.as_str())); 70 | 71 | // match task { 72 | // Some(task) => GetTaskResponse::Ok(Json(task.clone())), 73 | // None => GetTaskResponse::NotFound, 74 | // } 75 | 76 | GetTaskResponse::NotFound 77 | } 78 | 79 | #[oai( 80 | path = "/tasks/:id", 81 | method = "put", 82 | tag = "ApiTags::Task", 83 | operation_id = "update_task" 84 | )] 85 | async fn update_task(&self, _id: Path, _task: Json) -> GetTaskResponse { 86 | // let mut users = self.tasks.lock().await; 87 | // let task = users.iter_mut().find(|task| task.id == id.0.into()); 88 | // 89 | // match task { 90 | // Some(task) => { 91 | // *task = task.clone(); 92 | // GetTaskResponse::Ok(Json(task.clone())) 93 | // }, 94 | // None => GetTaskResponse::NotFound, 95 | // } 96 | 97 | GetTaskResponse::NotFound 98 | } 99 | 100 | #[oai( 101 | path = "/tasks/:id", 102 | method = "delete", 103 | tag = "ApiTags::Task", 104 | operation_id = "delete_task" 105 | )] 106 | async fn delete_task(&self, _id: Path) -> GetTaskResponse { 107 | // let mut users = self.tasks.lock().await; 108 | // let task = users.iter().find(|task| task.id == id.0.into()); 109 | 110 | // match task { 111 | // Some(task) => { 112 | // // users.remove_item(task); 113 | // GetTaskResponse::Ok(Json(task.clone())) 114 | // }, 115 | // None => GetTaskResponse::NotFound, 116 | // } 117 | 118 | GetTaskResponse::NotFound 119 | } 120 | 121 | #[oai( 122 | path = "/projects", 123 | method = "post", 124 | tag = "ApiTags::Project", 125 | operation_id = "create_project" 126 | )] 127 | async fn create_project(&self, task: Json) -> CreateProjectResponse { 128 | // let mut users = self.tasks.lock().await; 129 | // users.insert(0, task.0.clone()); 130 | 131 | CreateProjectResponse::Ok(Json(task.0)) 132 | } 133 | 134 | #[oai( 135 | path = "/projects", 136 | method = "get", 137 | tag = "ApiTags::Project", 138 | operation_id = "list_projects" 139 | )] 140 | async fn list_projects(&self) -> ListProjectsResponse { 141 | // let users = self.tasks.lock().await; 142 | // ListTasksResponse::Ok(Json(users.clone())) 143 | ListProjectsResponse::Ok(Json(vec![])) 144 | } 145 | } 146 | 147 | #[derive(ApiResponse)] 148 | enum CreateProjectResponse { 149 | /// Returns when the user is successfully created. 150 | #[oai(status = 200)] 151 | Ok(Json), 152 | } 153 | 154 | #[derive(ApiResponse)] 155 | enum ListProjectsResponse { 156 | /// Returns when the user is successfully created. 157 | #[oai(status = 200)] 158 | Ok(Json>), 159 | } 160 | #[derive(ApiResponse)] 161 | enum CreateTaskResponse { 162 | /// Returns when the user is successfully created. 163 | #[oai(status = 200)] 164 | Ok(Json), 165 | } 166 | 167 | #[derive(ApiResponse)] 168 | enum ListTasksResponse { 169 | /// Returns when the user is successfully created. 170 | #[oai(status = 200)] 171 | Ok(Json>), 172 | } 173 | 174 | #[derive(ApiResponse)] 175 | enum GetTaskResponse { 176 | /// Returns when the user is successfully created. 177 | // #[oai(status = 200)] 178 | // Ok(Json), 179 | #[oai(status = 404)] 180 | NotFound, 181 | } 182 | -------------------------------------------------------------------------------- /src/auth/engine.rs: -------------------------------------------------------------------------------- 1 | use std::error::Error; 2 | 3 | use argon2::{ 4 | password_hash::{rand_core::OsRng, SaltString}, 5 | Argon2, PasswordHash, PasswordHasher, PasswordVerifier, 6 | }; 7 | use oauth2::{ 8 | basic::BasicClient, reqwest::async_http_client, AuthUrl, AuthorizationCode, ClientId, 9 | ClientSecret, CsrfToken, RedirectUrl, Scope, TokenResponse, TokenUrl, 10 | }; 11 | 12 | use reqwest::Url; 13 | 14 | use super::{ 15 | core::PlexoAuthToken, 16 | jwt::{JWTEngine, PlexoAuthTokenClaims}, 17 | }; 18 | 19 | #[derive(Clone)] 20 | pub struct AuthEngine { 21 | pub jwt_engine: JWTEngine, 22 | 23 | github_client: Option, 24 | _google_client: Option, 25 | } 26 | 27 | impl AuthEngine { 28 | pub fn new( 29 | jwt_access_token_secret: String, 30 | jwt_refresh_token_secret: String, 31 | // 32 | github_client_id: Option, 33 | github_client_secret: Option, 34 | github_redirect_url: Option, 35 | ) -> Self { 36 | let mut github_client: Option = None; 37 | 38 | if let (Some(github_client_id), Some(github_client_secret), Some(github_redirect_url)) = 39 | (github_client_id, github_client_secret, github_redirect_url) 40 | { 41 | let github_client_id = ClientId::new(github_client_id.to_string()); 42 | let github_client_secret = ClientSecret::new(github_client_secret.to_string()); 43 | 44 | let auth_url = AuthUrl::new("https://github.com/login/oauth/authorize".to_string()) 45 | .expect("Invalid authorization endpoint URL"); 46 | let token_url = 47 | TokenUrl::new("https://github.com/login/oauth/access_token".to_string()) 48 | .expect("Invalid token endpoint URL"); 49 | 50 | github_client = Some( 51 | BasicClient::new( 52 | github_client_id, 53 | Some(github_client_secret), 54 | auth_url, 55 | Some(token_url), 56 | ) 57 | .set_redirect_uri( 58 | RedirectUrl::new(github_redirect_url.to_string()) 59 | .expect("Invalid redirect URL"), 60 | ), 61 | ); 62 | } 63 | 64 | // match (github_client_id, github_client_secret, github_redirect_url) { 65 | // (Some(github_client_id), Some(github_client_secret), Some(github_redirect_url)) => { 66 | // let github_client_id = ClientId::new(github_client_id.to_string()); 67 | // let github_client_secret = ClientSecret::new(github_client_secret.to_string()); 68 | 69 | // let auth_url = AuthUrl::new("https://github.com/login/oauth/authorize".to_string()) 70 | // .expect("Invalid authorization endpoint URL"); 71 | // let token_url = 72 | // TokenUrl::new("https://github.com/login/oauth/access_token".to_string()) 73 | // .expect("Invalid token endpoint URL"); 74 | 75 | // github_client = Some( 76 | // BasicClient::new( 77 | // github_client_id, 78 | // Some(github_client_secret), 79 | // auth_url, 80 | // Some(token_url), 81 | // ) 82 | // .set_redirect_uri( 83 | // RedirectUrl::new(github_redirect_url.to_string()) 84 | // .expect("Invalid redirect URL"), 85 | // ), 86 | // ); 87 | // } 88 | // _ => {} 89 | // } 90 | 91 | let jwt_engine = JWTEngine::new( 92 | jwt_access_token_secret.to_string(), 93 | jwt_refresh_token_secret.to_string(), 94 | ); 95 | 96 | Self { 97 | jwt_engine, 98 | github_client, 99 | _google_client: None, 100 | } 101 | } 102 | 103 | pub fn new_github_authorize_url(&self) -> Option<(Url, CsrfToken)> { 104 | self.github_client.as_ref().map(|client| { 105 | client 106 | .authorize_url(CsrfToken::new_random) 107 | .add_scope(Scope::new("user:email".to_string())) 108 | .url() 109 | }) 110 | } 111 | 112 | pub async fn exchange_github_code( 113 | &self, 114 | code: AuthorizationCode, 115 | _state: CsrfToken, 116 | ) -> Result { 117 | let token_result = self 118 | .github_client 119 | .as_ref() 120 | .unwrap() 121 | .exchange_code(code) 122 | .request_async(async_http_client) 123 | .await; 124 | 125 | match token_result { 126 | Ok(token) => Ok(token.access_token().secret().to_string()), 127 | Err(e) => Err(e.to_string()), 128 | } 129 | } 130 | 131 | pub fn extract_claims( 132 | &self, 133 | plexo_auth_token: &PlexoAuthToken, 134 | ) -> Result> { 135 | Ok(self 136 | .jwt_engine 137 | .decode_session_token(plexo_auth_token.0.as_str())?) 138 | } 139 | 140 | pub fn validate_password(&self, password: &str, password_hash: &str) -> bool { 141 | let Ok(parsed_hash) = PasswordHash::new(password_hash) else { 142 | return false; 143 | }; 144 | 145 | Argon2::default() 146 | .verify_password(password.as_bytes(), &parsed_hash) 147 | .is_ok() 148 | } 149 | 150 | pub fn hash_password(&self, password: &str) -> String { 151 | let salt = SaltString::generate(&mut OsRng); 152 | 153 | Argon2::default() 154 | .hash_password(password.as_bytes(), &salt) 155 | .unwrap() 156 | .to_string() 157 | } 158 | 159 | pub fn has_github_client(&self) -> bool { 160 | self.github_client.is_some() 161 | } 162 | 163 | pub fn has_google_client(&self) -> bool { 164 | self._google_client.is_some() 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /constitution.md: -------------------------------------------------------------------------------- 1 | # system 2 | 3 | Your task is to create a simplified Resources and Operations (R&O) representation of a specified piece of source code. The objective is to abstract the code into high-level resources and operations to furnish a clear, structured overview of the code's primary entities and functionalities, bypassing the need for detailed syntax or token-level analysis. 4 | 5 | Definitions: 6 | 7 | - A "Resource" refers to crucial structures, entities, or data types within the code. 8 | - An "Operation" refers to significant actions, functions, or methods executed within the code. 9 | 10 | Guidelines for R&O Representation: 11 | 12 | 1. Resources Identification: 13 | a. Library Imports: List the primary libraries or modules being imported. 14 | b. Input Filters: Catalog input structures or filters. 15 | c. Main Object: Identify the principal object, struct, or class. 16 | 17 | 2. Operations Identification: 18 | a. Under the main object, struct, or class, list the associated operations. 19 | b. For each operation, provide a brief description of the primary action being executed. 20 | 21 | 3. Structuring: 22 | a. Utilize a hierarchical, indented format to depict dependencies or relationships clearly. 23 | b. Ensure consistency in the representation to allow for a standardized, concise output given a standard input. 24 | 25 | 4. Conciseness and Abstraction: 26 | a. Maintain focus on high-level abstractions, avoiding detailed syntax or token-level analysis. 27 | b. Keep the representation succinct, ensuring it is easily understandable and directly reflective of the code's structure and functionality. 28 | 29 | Examples: 30 | 31 | input: 32 | path: /Users/bregy/Documents/minskylab/plexo-core/src/graphql/queries/resources.rs 33 | source: 34 | 35 | ```rust 36 | use std::str::FromStr; 37 | 38 | use async_graphql::{Context, InputObject, Object, Result}; 39 | use chrono::{DateTime, Utc}; 40 | use uuid::Uuid; 41 | 42 | use crate::{ 43 | graphql::auth::extract_context, 44 | sdk::{ 45 | activity::{Activity, ActivityOperationType, ActivityResourceType}, 46 | labels::Label, 47 | member::{Member, MemberRole}, 48 | project::Project, 49 | task::{Task, TaskPriority, TaskStatus}, 50 | team::{Team, TeamVisibility}, 51 | utilities::DateTimeBridge, 52 | }, 53 | }; 54 | 55 | 56 | #[derive(Default)] 57 | pub struct ResourcesQuery; 58 | 59 | #[derive(InputObject)] 60 | pub struct TaskFilter { 61 | // placeholder 62 | } 63 | 64 | #[derive(InputObject)] 65 | pub struct MemberFilter { 66 | // placeholder 67 | } 68 | 69 | #[derive(InputObject)] 70 | pub struct TeamFilter { 71 | // placeholder 72 | } 73 | 74 | #[derive(InputObject)] 75 | pub struct ProjectFilter { 76 | // placeholder 77 | } 78 | 79 | #[Object] 80 | impl ResourcesQuery { 81 | async fn tasks(&self, ctx: &Context<'_>, _filter: Option) -> Result> { 82 | // placeholder 83 | } 84 | 85 | async fn task_by_id(&self, ctx: &Context<'_>, id: Uuid) -> Result { 86 | // placeholder 87 | } 88 | 89 | async fn members( 90 | &self, 91 | ctx: &Context<'_>, 92 | _filter: Option, 93 | ) -> Result> { 94 | // placeholder 95 | } 96 | 97 | async fn member_by_id(&self, ctx: &Context<'_>, id: Uuid) -> Result { 98 | // placeholder 99 | } 100 | 101 | async fn member_by_email(&self, ctx: &Context<'_>, email: String) -> Result { 102 | // placeholder 103 | } 104 | 105 | async fn projects( 106 | &self, 107 | ctx: &Context<'_>, 108 | _filter: Option, 109 | ) -> Result> { 110 | // placeholder 111 | } 112 | 113 | async fn project_by_id(&self, ctx: &Context<'_>, id: Uuid) -> Result { 114 | // placeholder 115 | } 116 | 117 | async fn teams(&self, ctx: &Context<'_>, _filter: Option) -> Result> { 118 | // placeholder 119 | } 120 | 121 | async fn team_by_id(&self, ctx: &Context<'_>, id: Uuid) -> Result { 122 | // placeholder 123 | } 124 | 125 | async fn labels(&self, ctx: &Context<'_>) -> Result> { 126 | // placeholder 127 | } 128 | 129 | async fn me(&self, ctx: &Context<'_>) -> Result { 130 | // placeholder 131 | } 132 | 133 | async fn activity( 134 | &self, 135 | ctx: &Context<'_>, 136 | resource_type: Option, 137 | resource_id: Option, 138 | operation_type: Option, 139 | member_id: Option, 140 | ) -> Result> { 141 | // placeholder 142 | } 143 | } 144 | ``` 145 | 146 | output: 147 | 148 | ```yaml 149 | Resource: Library Imports 150 | - std, async_graphql, chrono, uuid, crate 151 | 152 | Resource: Input Filters 153 | - TaskFilter, MemberFilter, TeamFilter, ProjectFilter 154 | 155 | Resource: ResourcesQuery Object 156 | Operation: tasks 157 | - Query tasks from database 158 | Operation: task_by_id 159 | - Query a specific task by ID from database 160 | Operation: members 161 | - Query members from database 162 | Operation: member_by_id 163 | - Query a specific member by ID from database 164 | Operation: member_by_email 165 | - Query a specific member by email from database 166 | Operation: projects 167 | - Query projects from database 168 | Operation: project_by_id 169 | - Query a specific project by ID from database 170 | Operation: teams 171 | - Query teams from database 172 | Operation: team_by_id 173 | - Query a specific team by ID from database 174 | Operation: labels 175 | - Query labels from database 176 | Operation: me 177 | - Query the authenticated member's data from database 178 | Operation: activity 179 | - Query activity logs from database with optional filters 180 | ``` 181 | 182 | # input 183 | 184 | {{#if element.is_file}} 185 | 186 | path: {{element.path}} 187 | source: 188 | 189 | ``` 190 | {{element.content}} 191 | ``` 192 | 193 | {{else}} 194 | 195 | path: {{element.path}} 196 | sources: 197 | {{#each element.children as |child|}} 198 | 199 | - path: {{child.path}} 200 | source: 201 | ``` 202 | {{child.content}} 203 | ``` 204 | 205 | {{/each}} 206 | 207 | {{/if}} 208 | 209 | give me only the output (in plain yaml format, don't use yaml code box syntax, only a parsable yaml result). 210 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | Plexo | Open-Source and AI-Powered Project Management System for modern innovators 3 |

4 | 5 |

6 | ➡️ Live Demo ⬅️ 7 |

8 | 9 |

10 | GitHub Workflow Status 11 | Docker Image Size (latest by date) 12 | GitHub last commit 13 | GitHub issues 14 | GitHub 15 | Twitter Follow 16 | GitHub Repo stars 17 |

18 | 19 | # Plexo 20 | 21 | Plexo is an innovative, open-source project management platform that harnesses the power of AI to streamline the way you work. Designed to simplify task tracking within projects and teams, Plexo cuts through the complexities of traditional project management, replacing them with intuitive and efficient solutions. 22 | 23 | Plexo's advanced AI functionalities are the heart of the platform. The AI autonomously generates tasks necessary for project completion, taking into account the project's requirements, deadlines, and the team's capabilities. This intelligent task creation optimizes the planning process, freeing up your team to focus on the core work at hand. 24 | 25 |

26 | Plexo Platform Screenshot 27 |

28 | 29 | Plexo is designed to serve as a benchmark for project execution and description, promoting seamless interoperability among diverse teams and organizations. This is achieved by adhering to the principle that system designs reflect their organization's communication structure. This principle, known as Conway's Law, is deeply ingrained in Plexo, making it a highly effective tool for mirroring and enhancing team communication. 30 | 31 | Adopt Plexo to enhance your software project planning and elevate team synergy. 32 | 33 | ## Features 34 | 35 | - 🧠 **AI-Powered Suggestions**: Plexo provides intelligent suggestions to aid in project planning and task management. 36 | 37 | - 📈 **Active Task Tracking**: Follow the progress of tasks/issues in real-time within a project, team, or individual context. 38 | 39 | - 🤖 **Autonomous Task Creation**: Plexo can autonomously generate tasks necessary for project completion, optimizing the planning process. 40 | 41 | - 🤝 **Seamless Collaboration**: Plexo facilitates collaboration between team members, streamlining communication and increasing efficiency. 42 | 43 | - 🔀 **Interoperability**: Designed to become a standard in project description and execution, Plexo aims to enhance interoperability between different organizations and teams. 44 | 45 | - 🔓 **Open-Source and Free Forever**: Plexo is committed to remaining an open-source project, fostering a community of contributors and users. 46 | 47 | - 🍃 **Lightweight and Self-Hosted**: Plexo is designed to be lightweight and self-hostable, reducing dependencies and providing flexibility. 48 | 49 | - 🔄 **Conway's Law Inspired**: Plexo is modeled on the principle that organizations design systems are analogous to their communication structure, thus mirroring team communication in its project management system. 50 | 51 | ## Quick Start 52 | 53 | You can try our demo [here](https://demo.plexo.app/). And if you want to deploy your own instance of Plexo-core, actually you need a Postgresql database, a OpenAI API Key and a Github OAuth app. Then you can run the following command: 54 | 55 | ```bash 56 | docker run \ 57 | -p 8080:8080 \ 58 | -e DATABASE_URL="postgres://postgres:postgres@localhost:5432/plexo" \ 59 | -e OPENAI_API_KEY="" \ 60 | -e GITHUB_CLIENT_ID="" \ 61 | -e GITHUB_CLIENT_SECRET="" \ 62 | -e JWT_ACCESS_TOKEN_SECRET="" \ 63 | -e JWT_REFRESH_TOKEN_SECRET="" \ 64 | minskylab/plexo 65 | ``` 66 | 67 | ⚠️ We're working on a way to deploy Plexo-core without the need of a Github OAuth app. If you want to contribute, please check [this issue](https://github.com/minskylab/plexo-core/issues/9). 68 | 69 | 119 | 120 | ## Contribution 121 | 122 | We welcome all contributions to the Plexo project! Whether you're interested in fixing bugs, adding new features, or improving documentation, your input is greatly valued. 123 | 124 | ## License 125 | 126 | Plexo-core is released under both the MIT and Apache 2.0 licenses. Users are free to use, modify, and distribute the software. Comments and feedback are greatly appreciated. 127 | -------------------------------------------------------------------------------- /src/llm/suggestions.rs: -------------------------------------------------------------------------------- 1 | use async_graphql::{InputObject, SimpleObject}; 2 | use chrono::{DateTime, Local, Utc}; 3 | use serde::Deserialize; 4 | use serde_json::Result; 5 | use sqlx::{query, Pool, Postgres}; 6 | use uuid::Uuid; 7 | 8 | use crate::sdk::{ 9 | task::{Task, TaskPriority, TaskStatus}, 10 | utilities::DateTimeBridge, 11 | }; 12 | 13 | use super::openai::LLMEngine; 14 | 15 | #[derive(Clone)] 16 | pub struct AutoSuggestionsEngine { 17 | llm_engine: LLMEngine, 18 | pool: Box>, 19 | } 20 | 21 | #[derive(InputObject, Clone)] 22 | pub struct TaskSuggestionInput { 23 | pub title: Option, 24 | pub description: Option, 25 | pub status: Option, 26 | pub priority: Option, 27 | pub due_date: Option>, 28 | } 29 | 30 | #[derive(SimpleObject, Clone, Deserialize)] 31 | pub struct TaskSuggestionResult { 32 | pub title: String, 33 | pub description: String, 34 | pub status: TaskStatus, 35 | pub priority: TaskPriority, 36 | pub due_date: DateTime, 37 | } 38 | 39 | #[derive(SimpleObject, Clone, Deserialize)] 40 | pub struct SuggestionContext { 41 | project_id: Option, 42 | team_id: Option, 43 | } 44 | 45 | impl AutoSuggestionsEngine { 46 | pub fn new(pool: Box>) -> Self { 47 | let llm_engine = LLMEngine::new(); 48 | Self { llm_engine, pool } 49 | } 50 | 51 | fn calculate_task_fingerprint(task: Task) -> String { 52 | serde_json::to_string(&task).unwrap() 53 | } 54 | 55 | fn calculate_task_suggestion_fingerprint(task_suggestion: TaskSuggestionInput) -> String { 56 | format!( 57 | "Task Title: {} 58 | Task Description: {} 59 | Task Status: {} 60 | Task Priority: {} 61 | Task Due Date: {}", 62 | task_suggestion.title.unwrap_or("".to_string()), 63 | task_suggestion 64 | .description 65 | .unwrap_or("".to_string()), 66 | task_suggestion 67 | .status 68 | .map(|s| s.to_str()) 69 | .unwrap_or(""), 70 | task_suggestion 71 | .priority 72 | .map(|p| p.to_str()) 73 | .unwrap_or(""), 74 | task_suggestion 75 | .due_date 76 | .map(|d| d.to_rfc3339()) 77 | .unwrap_or("".to_string()), 78 | ) 79 | } 80 | 81 | async fn acquire_tasks_fingerprints(&self) -> Vec { 82 | let tasks = query!( 83 | r#" 84 | SELECT * 85 | FROM tasks 86 | LIMIT 10 87 | "#, 88 | ) 89 | .fetch_all(&*self.pool) 90 | .await 91 | .unwrap(); 92 | 93 | tasks 94 | .iter() 95 | .map(|r| Task { 96 | id: r.id, 97 | created_at: DateTimeBridge::from_offset_date_time(r.created_at), 98 | updated_at: DateTimeBridge::from_offset_date_time(r.updated_at), 99 | title: r.title.clone(), 100 | description: r.description.clone(), 101 | status: TaskStatus::from_optional_str(&r.status), 102 | priority: TaskPriority::from_optional_str(&r.priority), 103 | due_date: r.due_date.map(DateTimeBridge::from_offset_date_time), 104 | project_id: r.project_id, 105 | lead_id: r.lead_id, 106 | owner_id: r.owner_id, 107 | count: r.count, 108 | parent_id: r.parent_id, 109 | }) 110 | .map(Self::calculate_task_fingerprint) 111 | .collect::>() 112 | } 113 | 114 | pub async fn get_suggestions( 115 | &self, 116 | proto_task: TaskSuggestionInput, 117 | _context: Option, 118 | ) -> Result { 119 | let tasks_fingerprints = self.acquire_tasks_fingerprints().await; 120 | 121 | let system_message = "The user pass to you a list of tasks and you should predict the following based on the input of the user. 122 | Please return only a valid json with the following struct { 123 | title: String, 124 | description: String, 125 | status: TaskStatus, 126 | priority: TaskPriority, 127 | due_date: DateTime 128 | }".to_string(); 129 | 130 | let user_message = format!( 131 | " 132 | Current Time: 133 | {} 134 | 135 | Current Tasks Context: 136 | {} 137 | 138 | With the above context, complete the following task, only fill the fields: 139 | {}", 140 | Local::now(), 141 | tasks_fingerprints.join("\n\n"), 142 | Self::calculate_task_suggestion_fingerprint(proto_task), 143 | ); 144 | 145 | let result = self 146 | .llm_engine 147 | .chat_completion(system_message, user_message) 148 | .await; 149 | 150 | let suggestion_result: TaskSuggestionResult = serde_json::from_str(&result)?; 151 | 152 | Ok(suggestion_result) 153 | } 154 | 155 | pub async fn subdivide_task( 156 | &self, 157 | task_id: Uuid, 158 | subtasks: u32, 159 | ) -> Result> { 160 | let task = sqlx::query!( 161 | r#" 162 | SELECT * FROM tasks 163 | WHERE id = $1 164 | "#, 165 | task_id 166 | ) 167 | .fetch_one(&*self.pool) 168 | .await 169 | .unwrap(); 170 | 171 | let task = Task { 172 | id: task.id, 173 | created_at: DateTimeBridge::from_offset_date_time(task.created_at), 174 | updated_at: DateTimeBridge::from_offset_date_time(task.updated_at), 175 | title: task.title.clone(), 176 | description: task.description.clone(), 177 | status: TaskStatus::from_optional_str(&task.status), 178 | priority: TaskPriority::from_optional_str(&task.priority), 179 | due_date: task.due_date.map(DateTimeBridge::from_offset_date_time), 180 | project_id: task.project_id, 181 | lead_id: task.lead_id, 182 | owner_id: task.owner_id, 183 | count: task.count, 184 | parent_id: task.parent_id, 185 | }; 186 | 187 | let system_message = 188 | "The user pass to you one task and you should predict a list of subtasks. 189 | Please return only a valid json with the following struct [{ 190 | title: String, 191 | description: String, 192 | status: TaskStatus, 193 | priority: TaskPriority, 194 | due_date: DateTime 195 | }] 196 | For TaskStatus and TaskPriority, please use the following values: 197 | TaskStatus: None, Backlog, ToDo, InProgress, Done, Canceled 198 | TaskPriority: None, Low, Medium, High, Urgent 199 | " 200 | .to_string(); 201 | 202 | let user_message = format!( 203 | " 204 | Current Time: 205 | {} 206 | 207 | Parent Task: 208 | {} 209 | 210 | With the above context, generate {} subtasks.", 211 | Local::now(), 212 | Self::calculate_task_fingerprint(task), 213 | subtasks, 214 | ); 215 | 216 | let result = self 217 | .llm_engine 218 | .chat_completion(system_message, user_message) 219 | .await; 220 | 221 | let subtasks: Vec = serde_json::from_str(&result)?; 222 | 223 | Ok(subtasks) 224 | } 225 | 226 | // pub async fn get_ 227 | } 228 | -------------------------------------------------------------------------------- /src/sdk/task.rs: -------------------------------------------------------------------------------- 1 | use std::str::FromStr; 2 | 3 | use async_graphql::{ComplexObject, Context, Enum, Result, SimpleObject}; 4 | use chrono::{DateTime, Utc}; 5 | 6 | use async_graphql::dataloader::DataLoader; 7 | use poem_openapi::Object; 8 | use uuid::Uuid; 9 | 10 | use serde::Deserialize; 11 | 12 | use super::{labels::Label, member::Member, project::Project}; 13 | 14 | use super::loaders::{LabelLoader, MemberLoader, ProjectLoader, TaskLoader}; 15 | use crate::graphql::auth::extract_context; 16 | use poem_openapi::Enum as OpenApiEnum; 17 | use serde::Serialize; 18 | 19 | #[derive(SimpleObject, Object, Clone, Debug, Serialize)] 20 | #[graphql(complex)] 21 | pub struct Task { 22 | pub id: Uuid, 23 | pub created_at: DateTime, 24 | pub updated_at: DateTime, 25 | 26 | pub title: String, 27 | pub description: Option, 28 | 29 | pub owner_id: Uuid, 30 | 31 | pub status: TaskStatus, 32 | pub priority: TaskPriority, 33 | 34 | pub due_date: Option>, 35 | 36 | pub project_id: Option, 37 | pub lead_id: Option, 38 | 39 | pub count: i32, 40 | 41 | pub parent_id: Option, 42 | } 43 | 44 | #[ComplexObject] 45 | impl Task { 46 | pub async fn owner(&self, ctx: &Context<'_>) -> Result> { 47 | let (_plexo_engine, _member_id) = extract_context(ctx)?; 48 | 49 | let loader = ctx.data::>().unwrap(); 50 | 51 | //match to see is project_id is none 52 | Ok(loader.load_one(self.owner_id).await.unwrap()) 53 | } 54 | 55 | pub async fn leader(&self, ctx: &Context<'_>) -> Result> { 56 | let (_plexo_engine, _member_id) = extract_context(ctx)?; 57 | 58 | let loader = ctx.data::>().unwrap(); 59 | 60 | //match to see is project_id is none 61 | Ok(match self.lead_id { 62 | Some(lead_id) => loader.load_one(lead_id).await.unwrap(), 63 | None => None, 64 | }) 65 | } 66 | 67 | pub async fn project(&self, ctx: &Context<'_>) -> Result> { 68 | let (_plexo_engine, _member_id) = extract_context(ctx)?; 69 | 70 | let loader = ctx.data::>().unwrap(); 71 | 72 | //match to see is project_id is none 73 | Ok(match self.project_id { 74 | Some(project_id) => loader.load_one(project_id).await.unwrap(), 75 | None => None, 76 | }) 77 | } 78 | 79 | pub async fn assignees(&self, ctx: &Context<'_>) -> Result> { 80 | let (plexo_engine, _member_id) = extract_context(ctx)?; 81 | 82 | let loader = ctx.data::>().unwrap(); 83 | 84 | let ids: Vec = sqlx::query!( 85 | r#" 86 | SELECT assignee_id FROM tasks_by_assignees 87 | WHERE task_id = $1 88 | "#, 89 | &self.id 90 | ) 91 | .fetch_all(&*plexo_engine.pool) 92 | .await 93 | .unwrap() 94 | .into_iter() 95 | .map(|id| id.assignee_id) 96 | .collect(); 97 | 98 | let members_map = loader.load_many(ids.clone()).await.unwrap(); 99 | 100 | let members: &Vec = &ids 101 | .into_iter() 102 | .map(|id| members_map.get(&id).unwrap().clone()) 103 | .collect(); 104 | 105 | Ok(members.clone()) 106 | } 107 | 108 | pub async fn labels(&self, ctx: &Context<'_>) -> Result> { 109 | let (plexo_engine, _member_id) = extract_context(ctx)?; 110 | 111 | let loader = ctx.data::>().unwrap(); 112 | 113 | let ids: Vec = sqlx::query!( 114 | r#" 115 | SELECT label_id FROM labels_by_tasks 116 | WHERE task_id = $1 117 | "#, 118 | &self.id 119 | ) 120 | .fetch_all(&*plexo_engine.pool) 121 | .await 122 | .unwrap() 123 | .into_iter() 124 | .map(|id| id.label_id) 125 | .collect(); 126 | 127 | let labels_map = loader.load_many(ids.clone()).await.unwrap(); 128 | 129 | let labels: &Vec