Feat: add web, database and discord micro-services (#1)
Rust / Run tests (push) Failing after 58s Details

Reviewed-on: #1
Co-authored-by: DataHearth <antoine.l@antoine-langlois.net>
Co-committed-by: DataHearth <antoine.l@antoine-langlois.net>
This commit is contained in:
DataHearth 2023-08-10 11:43:00 +02:00 committed by Antoine Langlois
parent e0b19f8eb4
commit 5ba1965127
59 changed files with 7851 additions and 649 deletions

View File

@ -1,9 +1,17 @@
**/.git*
target
.env
**/*.db*
**/.env
Dockerfile
.gitignore
*.db
*.db.wal
.git*
.dockerignore
LICENSE
LICENSE
README.md
frontend/node_modules
.svelte-kit
build
frontend/pnpm-lock.yaml

2
.gitattributes vendored Normal file
View File

@ -0,0 +1,2 @@
*.bmp filter=lfs diff=lfs merge=lfs -text
*.png filter=lfs diff=lfs merge=lfs -text

View File

@ -0,0 +1,76 @@
name: Docker bot
run-name: Build and push Docker image
on:
push:
tags:
- "v*.*.*-bot"
env:
GITEA_REGISTRY: gitea.antoine-langlois.net
GH_REGISTRY: ghcr.io
GH_REPOSITORY: wyll-io/tech-bot
GITEA_REPOSITORY: datahearth/tech-bot
jobs:
build-push:
name: Build and push Docker image
runs-on: debian-docker
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Setup Docker buildx
uses: docker/setup-buildx-action@v2
- name: Log into registry Docker
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_TOKEN }}
- name: Log into registry Gitea
uses: docker/login-action@v2
with:
registry: ${{ env.GITEA_REGISTRY }}
username: ${{ gitea.repository_owner }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Log into registry GitHub
uses: docker/login-action@v2
with:
registry: ${{ env.GH_REGISTRY }}
username: ${{ gitea.repository_owner }}
password: ${{ secrets.GH_REGISTRY_TOKEN }}
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@v4
with:
images: |
${{ env.GITEA_REPOSITORY }}
${{ env.GITEA_REGISTRY }}/${{ env.GITEA_REPOSITORY }}
${{ env.GH_REGISTRY }}/${{ env.GH_REPOSITORY }}
tags: |
type=semver,pattern=bot-latest
type=semver,pattern=bot-{{version}}
type=semver,pattern=bot-{{major}}.{{minor}}
type=raw,value=latest,enable=false
- name: Build and push Docker image
id: build-and-push
uses: docker/build-push-action@v4
with:
context: .
push: true
target: bot
cache-to: type=inline
cache-from: |
type=registry,ref=${{ env.GITEA_REPOSITORY }}:bot-latest
type=registry,ref=${{ env.GITEA_REGISTRY }}/${{ env.GITEA_REPOSITORY }}:bot-latest
type=registry,ref=${{ env.GH_REGISTRY }}/${{ env.GH_REPOSITORY }}:bot-latest
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

View File

@ -0,0 +1,76 @@
name: Docker database
run-name: Build and push Docker image
on:
push:
tags:
- "v*.*.*-database"
env:
GITEA_REGISTRY: gitea.antoine-langlois.net
GH_REGISTRY: ghcr.io
GH_REPOSITORY: wyll-io/tech-bot
GITEA_REPOSITORY: datahearth/tech-bot
jobs:
build-push:
name: Build and push Docker image
runs-on: debian-docker
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Setup Docker buildx
uses: docker/setup-buildx-action@v2
- name: Log into registry Docker
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_TOKEN }}
- name: Log into registry Gitea
uses: docker/login-action@v2
with:
registry: ${{ env.GITEA_REGISTRY }}
username: ${{ gitea.repository_owner }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Log into registry GitHub
uses: docker/login-action@v2
with:
registry: ${{ env.GH_REGISTRY }}
username: ${{ gitea.repository_owner }}
password: ${{ secrets.GH_REGISTRY_TOKEN }}
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@v4
with:
images: |
${{ env.GITEA_REPOSITORY }}
${{ env.GITEA_REGISTRY }}/${{ env.GITEA_REPOSITORY }}
${{ env.GH_REGISTRY }}/${{ env.GH_REPOSITORY }}
tags: |
type=semver,pattern=database-latest
type=semver,pattern=database-{{version}}
type=semver,pattern=database-{{major}}.{{minor}}
type=raw,value=latest,enable=false
- name: Build and push Docker image
id: build-and-push
uses: docker/build-push-action@v4
with:
context: .
push: true
target: database
cache-to: type=inline
cache-from: |
type=registry,ref=${{ env.GITEA_REPOSITORY }}:database-latest
type=registry,ref=${{ env.GITEA_REGISTRY }}/${{ env.GITEA_REPOSITORY }}:database-latest
type=registry,ref=${{ env.GH_REGISTRY }}/${{ env.GH_REPOSITORY }}:database-latest
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

View File

@ -0,0 +1,76 @@
name: Docker web
run-name: Build and push Docker image
on:
push:
tags:
- "v*.*.*-web"
env:
GITEA_REGISTRY: gitea.antoine-langlois.net
GH_REGISTRY: ghcr.io
GH_REPOSITORY: wyll-io/tech-bot
GITEA_REPOSITORY: datahearth/tech-bot
jobs:
build-push:
name: Build and push Docker image
runs-on: debian-docker
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Setup Docker buildx
uses: docker/setup-buildx-action@v2
- name: Log into registry Docker
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_TOKEN }}
- name: Log into registry Gitea
uses: docker/login-action@v2
with:
registry: ${{ env.GITEA_REGISTRY }}
username: ${{ gitea.repository_owner }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Log into registry GitHub
uses: docker/login-action@v2
with:
registry: ${{ env.GH_REGISTRY }}
username: ${{ gitea.repository_owner }}
password: ${{ secrets.GH_REGISTRY_TOKEN }}
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@v4
with:
images: |
${{ env.GITEA_REPOSITORY }}
${{ env.GITEA_REGISTRY }}/${{ env.GITEA_REPOSITORY }}
${{ env.GH_REGISTRY }}/${{ env.GH_REPOSITORY }}
tags: |
type=semver,pattern=web-latest
type=semver,pattern=web-{{version}}
type=semver,pattern=web-{{major}}.{{minor}}
type=raw,value=latest,enable=false
- name: Build and push Docker image
id: build-and-push
uses: docker/build-push-action@v4
with:
context: .
push: true
target: web
cache-to: type=inline
cache-from: |
type=registry,ref=${{ env.GITEA_REPOSITORY }}:web-latest
type=registry,ref=${{ env.GITEA_REGISTRY }}/${{ env.GITEA_REPOSITORY }}:web-latest
type=registry,ref=${{ env.GH_REGISTRY }}/${{ env.GH_REPOSITORY }}:web-latest
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

View File

@ -16,8 +16,13 @@ env:
jobs:
build-push:
name: Build and push Docker image
runs-on: debian-docker
strategy:
matrix:
target: [database, bot, web]
steps:
- name: Checkout
uses: actions/checkout@v3
@ -54,8 +59,10 @@ jobs:
${{ env.GITEA_REGISTRY }}/${{ env.GITEA_REPOSITORY }}
${{ env.GH_REGISTRY }}/${{ env.GH_REPOSITORY }}
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern=${{ matrix.target }}-latest
type=semver,pattern=${{ matrix.target }}-{{version}}
type=semver,pattern=${{ matrix.target }}-{{major}}.{{minor}}
type=raw,value=latest,enable=false
- name: Build and push Docker image
id: build-and-push
@ -63,10 +70,11 @@ jobs:
with:
context: .
push: true
target: ${{ matrix.target }}
cache-to: type=inline
cache-from: |
type=registry,ref=${{ env.GITEA_REPOSITORY }}:latest
type=registry,ref=${{ env.GITEA_REGISTRY }}/${{ env.GITEA_REPOSITORY }}:latest
type=registry,ref=${{ env.GH_REGISTRY }}/${{ env.GH_REPOSITORY }}:latest
type=registry,ref=${{ env.GITEA_REPOSITORY }}:${{ matrix.target }}-latest
type=registry,ref=${{ env.GITEA_REGISTRY }}/${{ env.GITEA_REPOSITORY }}:${{ matrix.target }}-latest
type=registry,ref=${{ env.GH_REGISTRY }}/${{ env.GH_REPOSITORY }}:${{ matrix.target }}-latest
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

View File

@ -23,4 +23,4 @@ jobs:
uses: actions/checkout@v3
- name: Run tests
run: cargo test --verbose
run: cargo build --verbose

3
.gitignore vendored
View File

@ -1,4 +1,5 @@
/target
.env
*.db
*.wal
*.wal
node_modules

1769
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,15 +1,2 @@
[package]
name = "tech-bot"
version = "0.1.0"
edition = "2021"
authors = ["Antoine Langlois <antoine.l@antoine-langlois.net>"]
description = "A Discord bot for the Tech channel"
[dependencies]
once_cell = "1.17"
poise = "0.5"
polodb_core = "4.4"
regex = "1.8"
serde = { version = "1.0", features = ["derive"] }
tokio = { version = "1.28", features = ["macros", "rt-multi-thread"] }
url = "2.3"
[workspace]
members = ["database", "bot"]

View File

@ -1,14 +1,34 @@
FROM rust:1 as builder
# ********************************
# * Build the bot and database *
# ********************************
FROM rust:1 as rust-builder
WORKDIR /app
COPY . .
COPY Cargo.toml Cargo.lock ./
COPY bot /app/bot
COPY database /app/database
RUN cargo build --release
RUN cargo build -r --workspace
FROM gcr.io/distroless/cc
# ********************************
# * Deploy the bot *
# ********************************
COPY --from=builder /app/target/release/tech-bot /tech-bot
FROM gcr.io/distroless/cc as bot
COPY --from=rust-builder /app/target/release/bot /bot
CMD [ "/bot" ]
# ********************************
# * Deploy the database *
# ********************************
FROM gcr.io/distroless/cc as database
COPY --from=rust-builder /app/target/release/database /database
ENV DB_PATH=/app/tech-bot.db
@ -16,4 +36,31 @@ WORKDIR /app
VOLUME [ "/app" ]
CMD [ "/tech-bot" ]
CMD [ "/database" ]
# ********************************
# * Build the web *
# ********************************
FROM node:18-slim as web-builder
WORKDIR /app
COPY frontend/ ./
RUN npm install
RUN npm run build
FROM node:18-alpine3.18 as web
WORKDIR /app
COPY --from=web-builder /app/build ./build
COPY --from=web-builder /app/node_modules ./node_modules
COPY --from=web-builder /app/package.json /app/package-lock.json ./
ENV DOTENV_CONFIG_PATH=/app/config/.env
VOLUME [ "/app/config" ]
CMD [ "node", "-r", "dotenv/config", "build" ]

108
README.md
View File

@ -11,50 +11,110 @@ Save technologies you want to share/remember in a simple way inside Discord.
#### Docker
```bash
# BOT
export DISCORD_TOKEN=TOKEN
export ADMIN_USERS=123456789,987654321
export GRAPHQL_ENDPOINT="http://host/graphql"
# DATABASE
export DB_PATH="/path/to/db/database.db"
# FRONTEND
export FRONT_VOLUME
cat << EOF > $FRONT_VOLUME/.env
PORT=3000
HOST=127.0.0.1
PUBLIC_GRAPHQL_ENDPOINT="$GRAPHQL_ENDPOINT"
CLIENT_ID="DISCORD_APP_ID"
CLIENT_SECRET="DISCORD_APP_SECRET"
ORIGIN="http://host"
EOF
docker run -d \
--name tech-bot \
--name tech-bot-bot \
-e DISCORD_TOKEN=$DISCORD_TOKEN \
-e ADMIN_USERS=$ADMIN_USERS \
-v /path/to/tech-bot/data:/data \
-e GRAPHQL_ENDPOINT=$GRAPHQL_ENDPOINT \
--restart unless-stopped \
gitea.antoine-langlois.net/datahearth/tech-bot
gitea.antoine-langlois.net/datahearth/tech-bot:bot-latest
docker run -d \
--name tech-bot-database \
-v $DB_PATH:/app/database.db \
--restart unless-stopped \
gitea.antoine-langlois.net/datahearth/tech-bot:database-latest
docker run -d \
--name tech-bot-front \
-v $FRONT_VOLUME:/app/config \
-p ${EXTERNAL_PORT}:${INTERNAL_PORT} \
--restart unless-stopped \
gitea.antoine-langlois.net/datahearth/tech-bot:front-latest
```
#### Manually
```bash
export DISCORD_TOKEN=TOKEN
export ADMIN_USERS=123456789,987654321
export DB_PATH=database.db
# Setup
git clone https://gitea.antoine-langlois.net/datahearth/tech-bot.git
cd tech-bot
# Database & Bot
## Build
cargo build --release
./target/release/tech-bot
# Run
. ./target/release/tech-bot/database
. ./target/release/tech-bot/bot
# Frontend
cd frontend
## Build
pnpm install --prod --frozen-lockfile
pnpm build
## Run
export PORT=3000
export HOST=127.0.0.1
export PUBLIC_GRAPHQL_ENDPOINT="$GRAPHQL_ENDPOINT"
export CLIENT_ID="DISCORD_APP_ID"
export CLIENT_SECRET="DISCORD_APP_SECRET"
export ORIGIN="http://host"
node -r dotenv/config build
```
### Usage
```bash
/tech help
```
```text
Hello fellow human! I am a bot that can help you adding new technologies to a git repository.
```bash
/tech add <Technology name> <Technology link> <OPTIONAL: Technology tags (comma separated)>
```
To add a new technology, just type:
```bash
/tech list
```
/add <technology> <link>
```bash
/tech search <Technology name> <OPTIONAL: Regex> <OPTIONAL: Technology tags (comma separated)>
```
To list all technologies, just type:
/list
To search for a technology, just type:
/search <technology>
To remove a technology, you need to have the permission to remote a tech from the list.
If so, just type:
/remove <technology>
To update a technology, you need to have the permission to update a tech from the list.
If so, just type:
/update <UUID> <technology> <link> <tags>
To get help, just type:
/help
```bash
/tech remove <tech-name>
```

17
bot/Cargo.toml Normal file
View File

@ -0,0 +1,17 @@
[package]
name = "bot"
version = "0.1.0"
edition = "2021"
authors = ["Antoine Langlois <antoine.l@antoine-langlois.net>"]
description = "Discord bot"
[dependencies]
chrono = { version = "0.4", features = ["serde"] }
dotenvy = "0.15"
graphql_client = { version = "0.13", features = ["reqwest"] }
poise = "0.5"
reqwest = { version = "0.11", features = ["json"] }
serde = { version = "1.0.179", features = ["derive"] }
tokio = { version = "1.28", features = ["macros", "rt-multi-thread"] }
url = "2.3"
uuid = { version = "1.4", features = ["v4", "serde"] }

53
bot/graphql/query.graphql Normal file
View File

@ -0,0 +1,53 @@
query GetTechnology($name: String!, $options: String!, $tags: [String!]!) {
technology(name: $name, options: $options, tags: $tags) {
id
link
name
tags
userId
createdAt
updatedAt
}
}
query GetTechnologies {
technologies {
id
link
name
tags
userId
createdAt
updatedAt
}
}
mutation CreateTechnology(
$name: String!
$link: String!
$tags: [String!]!
$userId: String!
) {
createTechnology(name: $name, link: $link, tags: $tags, userId: $userId) {
id
link
name
tags
userId
createdAt
updatedAt
}
}
mutation DeleteTechnology($id: Uuid!) {
deleteTechnology(id: $id)
}
mutation DeleteTechnologies($ids: [Uuid!]!) {
deleteTechnologies(ids: $ids)
}
mutation UpdateTechnology(
$id: Uuid!
$name: String
$link: String
$tags: [String!]
) {
updateTechnology(id: $id, name: $name, link: $link, tags: $tags)
}

1448
bot/graphql/schema.json Normal file

File diff suppressed because it is too large Load Diff

289
bot/src/commands.rs Normal file
View File

@ -0,0 +1,289 @@
use std::env;
use graphql_client::{GraphQLQuery, Response};
use poise::command;
use reqwest::Client;
use url::Url;
use uuid::Uuid;
use crate::graphql::{
create_technology, delete_technology, get_technologies, get_technology, update_technology,
CreateTechnology, DeleteTechnology, GetTechnologies, GetTechnology, UpdateTechnology,
};
const HELP_MESSAGE: &str = "
Hello fellow human! I am a bot that can help you adding new technologies to a git repository.
To add a new technology, just type:
```
/add <technology> <link>
```
To list all technologies, just type:
```
/list
```
To search for a technology, just type:
```
/search <technology>
```
To remove a technology, you need to have the permission to remote a tech from the list.
If so, just type:
```
/remove <technology>
```
To update a technology, you need to have the permission to update a tech from the list.
If so, just type:
```
/update <UUID> <technology> <link> <tags>
```
To get help, just type:
```
/help
```
";
type Error = Box<dyn std::error::Error + Send + Sync>;
type Context<'a> = poise::Context<'a, MsgData, Error>;
pub struct MsgData {
pub client: Client,
}
/// Show help for all commands
#[command(slash_command, prefix_command)]
pub async fn help(ctx: Context<'_>) -> Result<(), Error> {
ctx.say(HELP_MESSAGE).await?;
Ok(())
}
/// Add a new technology to the technologies list
#[command(slash_command, prefix_command)]
pub async fn add(
ctx: Context<'_>,
#[description = "Technology name"] technology: String,
#[description = "Git repository link"] link: String,
#[description = "Technology tags (comma separated)"] tags: Option<String>,
) -> Result<(), Error> {
if !Url::parse(&link).is_ok() {
ctx.say(format!("Link {link} is not a valid URL")).await?;
return Ok(());
}
let tags = tags.unwrap_or(String::new());
let final_tags = tags.split(',').map(|s| s.to_string());
let rsp: Response<create_technology::ResponseData> = ctx
.data()
.client
.post(env::var("GRAPHQL_ENDPOINT").expect("GRAPHQL_ENDPOINT not set"))
.json(&CreateTechnology::build_query(
create_technology::Variables {
name: technology,
link,
tags: final_tags.collect::<Vec<String>>(),
user_id: ctx.author().id.to_string(),
},
))
.send()
.await?
.json()
.await?;
if let Some(errors) = rsp.errors {
ctx.say(format!("command failed: {:?}", errors)).await?;
return Ok(());
}
let data = rsp.data.unwrap().create_technology;
ctx.say(format!(
"Added {} with link {} and tags [{}] (id: {})",
data.name,
data.link,
data.tags.join(","),
data.id
))
.await?;
Ok(())
}
/// List all available technologies
#[command(slash_command, prefix_command)]
pub async fn list(ctx: Context<'_>) -> Result<(), Error> {
let rsp: Response<get_technologies::ResponseData> = ctx
.data()
.client
.post(env::var("GRAPHQL_ENDPOINT").expect("GRAPHQL_ENDPOINT not set"))
.json(&GetTechnologies::build_query(
get_technologies::Variables {},
))
.send()
.await?
.json()
.await?;
if let Some(errors) = rsp.errors {
ctx.say(format!("command failed: {:?}", errors)).await?;
return Ok(());
}
let techs = rsp.data.unwrap().technologies;
if techs.len() == 0 {
ctx.say("No technologies saved").await?;
return Ok(());
}
ctx.say(format!(
"Saved technologies: \n{}",
techs
.iter()
.map(|tech| format!(
"[{}]({}) **{}** (id: {})",
tech.name,
tech.link,
tech.tags.join(","),
tech.id
))
.collect::<Vec<String>>()
.join("\n")
))
.await?;
Ok(())
}
/// Search for a technology
#[command(slash_command, prefix_command)]
pub async fn search(
ctx: Context<'_>,
#[description = "Technology name (can be a regex string)"] technology: String,
#[description = "Regex options"] options: Option<String>,
#[description = "Technology tags (comma separated)"] tags: Option<String>,
) -> Result<(), Error> {
let tags = tags.unwrap_or(String::new());
let final_tags = tags.split(',').map(|s| s.to_string());
let rsp: Response<get_technology::ResponseData> = ctx
.data()
.client
.post(env::var("GRAPHQL_ENDPOINT").expect("GRAPHQL_ENDPOINT not set"))
.json(&GetTechnology::build_query(get_technology::Variables {
name: technology,
options: options.unwrap_or(String::new()),
tags: final_tags.collect::<Vec<String>>(),
}))
.send()
.await?
.json()
.await?;
if let Some(errors) = rsp.errors {
ctx.say(format!("command failed: {:?}", errors)).await?;
return Ok(());
}
let found_techs = rsp.data.unwrap().technology;
if found_techs.len() == 0 {
ctx.say("No technologies found").await?;
return Ok(());
}
ctx.say(format!(
"Found technologies: {}",
found_techs
.iter()
.map(|tech| format!("\nName: {} => Link: {}", tech.name, tech.link))
.collect::<Vec<String>>()
.join("\n")
))
.await?;
Ok(())
}
/// Remove a technology from the technologies list
#[command(slash_command, prefix_command)]
pub async fn remove(
ctx: Context<'_>,
#[description = "Technology name"] id: Uuid,
) -> Result<(), Error> {
let rsp: Response<delete_technology::ResponseData> = ctx
.data()
.client
.post(env::var("GRAPHQL_ENDPOINT").expect("GRAPHQL_ENDPOINT not set"))
.json(&DeleteTechnology::build_query(
delete_technology::Variables { id },
))
.send()
.await?
.json()
.await?;
if let Some(errors) = rsp.errors {
ctx.say(format!("command failed: {:?}", errors)).await?;
return Ok(());
}
ctx.say("Technology removed").await?;
Ok(())
}
// Update a technology
#[command(slash_command, prefix_command)]
pub async fn update(
ctx: Context<'_>,
#[description = "Technology UUID"] id: Uuid,
#[description = "New name"] name: Option<String>,
#[description = "New link"] link: Option<String>,
#[description = "New tags"] tags: Option<String>,
) -> Result<(), Error> {
if name.is_none() && link.is_none() && tags.is_none() {
ctx.say("Nothing to update").await?;
return Ok(());
}
let mut final_tags = None;
if let Some(tags) = tags {
let res = tags
.split(",")
.map(|s| s.to_string())
.collect::<Vec<String>>();
final_tags = Some(res);
}
let rsp: Response<update_technology::ResponseData> = ctx
.data()
.client
.post(env::var("GRAPHQL_ENDPOINT").expect("GRAPHQL_ENDPOINT not set"))
.json(&UpdateTechnology::build_query(
update_technology::Variables {
id,
name,
link,
tags: final_tags,
},
))
.send()
.await?
.json()
.await?;
if let Some(errors) = rsp.errors {
ctx.say(format!("command failed: {:?}", errors)).await?;
return Ok(());
}
ctx.say("Technology updated").await?;
Ok(())
}

50
bot/src/graphql.rs Normal file
View File

@ -0,0 +1,50 @@
use graphql_client::GraphQLQuery;
use uuid::Uuid;
#[derive(GraphQLQuery)]
#[graphql(
schema_path = "graphql/schema.json",
query_path = "graphql/query.graphql",
response_derives = "Debug"
)]
pub struct CreateTechnology;
#[derive(GraphQLQuery)]
#[graphql(
schema_path = "graphql/schema.json",
query_path = "graphql/query.graphql",
response_derives = "Debug"
)]
pub struct GetTechnologies;
#[derive(GraphQLQuery)]
#[graphql(
schema_path = "graphql/schema.json",
query_path = "graphql/query.graphql",
response_derives = "Debug"
)]
pub struct GetTechnology;
#[derive(GraphQLQuery)]
#[graphql(
schema_path = "graphql/schema.json",
query_path = "graphql/query.graphql",
response_derives = "Debug"
)]
pub struct DeleteTechnology;
#[derive(GraphQLQuery)]
#[graphql(
schema_path = "graphql/schema.json",
query_path = "graphql/query.graphql",
response_derives = "Debug"
)]
pub struct DeleteTechnologies;
#[derive(GraphQLQuery)]
#[graphql(
schema_path = "graphql/schema.json",
query_path = "graphql/query.graphql",
response_derives = "Debug"
)]
pub struct UpdateTechnology;

View File

@ -1,17 +1,23 @@
mod commands;
mod database;
mod graphql;
use poise::serenity_prelude as serenity;
use commands::{add, add_auth_user, help, list, remove, search, MsgData};
use database::DB;
use polodb_core::Database;
use commands::{add, help, list, remove, search, update, MsgData};
use reqwest::Client;
#[tokio::main]
async fn main() {
match dotenvy::from_path(std::env::var("DOTENV_PATH").unwrap_or_else(|_| "/.env".to_string())) {
Ok(_) => {}
Err(e) => {
println!("Failed to load .env file: {}", e);
}
}
let framework = poise::Framework::builder()
.options(poise::FrameworkOptions {
commands: vec![help(), add(), list(), search(), remove(), add_auth_user()],
commands: vec![help(), add(), list(), search(), remove(), update()],
..Default::default()
})
.token(std::env::var("DISCORD_TOKEN").expect("missing DISCORD_TOKEN"))
@ -21,13 +27,11 @@ async fn main() {
.setup(|ctx, _ready, framework| {
Box::pin(async move {
poise::builtins::register_globally(ctx, &framework.options().commands).await?;
Ok(MsgData {})
Ok(MsgData {
client: Client::new(),
})
})
});
DB.get_or_init(|| {
Database::open_file(std::env::var("DB_PATH").expect("missing DB_PATH"))
.expect("failed to initialize database")
});
framework.run().await.unwrap();
}

23
database/Cargo.toml Normal file
View File

@ -0,0 +1,23 @@
[package]
name = "database"
version = "0.1.0"
edition = "2021"
authors = ["Antoine Langlois <antoine.l@antoine-langlois.net>"]
description = "Database endpoint with GraphQL for tech-bot"
[dependencies]
actix-cors = "0.6"
actix-web = "4.3"
chrono = { version = "0.4", features = ["serde"] }
dotenvy = "0.15"
env_logger = "0.10"
# until v0.16 is released, use master for uuid support
juniper = { git = "https://github.com/graphql-rust/juniper.git", features = [
"uuid",
"chrono",
] }
juniper_actix = "0.4.0"
log = "0.4"
polodb_core = { git = "https://github.com/DataHearth/PoloDB.git", branch = "fix/in-operator" }
serde = { version = "1.0", features = ["derive"] }
uuid = { version = "1.4", features = ["serde", "v4"] }

290
database/src/graphql.rs Normal file
View File

@ -0,0 +1,290 @@
use std::io::{Error, ErrorKind};
use juniper::{
graphql_object, graphql_value, Context, EmptySubscription, FieldError, FieldResult, RootNode,
};
use polodb_core::{
bson::{doc, Regex},
Database,
};
use uuid::Uuid;
use crate::schema::{database, query};
pub struct DB {
db: Database,
}
impl Context for DB {}
pub struct QueryRoot;
pub struct MutationRoot;
pub type Schema = RootNode<'static, QueryRoot, MutationRoot, EmptySubscription<DB>>;
#[graphql_object(Context = DB)]
impl QueryRoot {
fn technology(
context: &DB,
name: String,
options: Option<String>,
tags: Option<Vec<String>>,
) -> FieldResult<Vec<query::Technology>> {
context
.search_tech(
name,
options.unwrap_or(String::new()),
&tags
.unwrap_or(Vec::new())
.iter()
.map(|s| s.as_str())
.collect::<Vec<&str>>(),
)
.map_err(map_fielderror)
.map(|v| {
v.iter()
.map(|v1| query::Technology {
_id: v1._id,
link: v1.link.clone().into(),
name: v1.name.clone().into(),
tags: v1.tags.clone().into(),
user_id: v1.user_id.to_string(),
created_at: v1.created_at.clone(),
updated_at: v1.updated_at.clone(),
})
.collect()
})
}
fn technologies(context: &DB) -> FieldResult<Vec<query::Technology>> {
context.list_tech().map_err(map_fielderror).map(|v| {
v.iter()
.map(|v1| query::Technology {
_id: v1._id,
link: v1.link.clone().into(),
name: v1.name.clone().into(),
tags: v1.tags.clone().into(),
user_id: v1.user_id.to_string(),
created_at: v1.created_at.clone(),
updated_at: v1.updated_at.clone(),
})
.collect()
})
}
}
#[graphql_object(Context = DB)]
impl MutationRoot {
fn create_technology(
context: &DB,
name: String,
link: String,
tags: Vec<String>,
user_id: String,
) -> FieldResult<query::Technology> {
Ok(context
.add_tech(
&name,
&link,
&tags.iter().map(|s| s.as_str()).collect::<Vec<&str>>(),
user_id.parse::<i64>().map_err(|e| {
let err_str = e.to_string();
FieldError::new(
"failed to convert string user_id to i64",
graphql_value!({ "error": err_str }),
)
})?,
)
.map_err(map_fielderror)?)
.map(|v| v.into())
}
fn delete_technology(context: &DB, id: Uuid) -> FieldResult<Uuid> {
context.remove_tech(id).map_err(map_fielderror)?;
Ok(id)
}
fn delete_technologies(context: &DB, ids: Vec<Uuid>) -> FieldResult<Vec<Uuid>> {
context
.bulk_remove_technologies(ids.clone())
.map_err(map_fielderror)?;
Ok(ids)
}
fn update_technology(
context: &DB,
id: Uuid,
name: Option<String>,
link: Option<String>,
tags: Option<Vec<String>>,
) -> FieldResult<bool> {
context
.edit_tech(id, name, link, tags)
.map_err(map_fielderror)?;
Ok(true)
}
}
impl DB {
pub fn new() -> Self {
Self {
db: Database::open_file(std::env::var("DB_PATH").expect("missing DB_PATH"))
.expect("failed to initialize database"),
}
}
/// Add a new technology to the database.
pub fn add_tech(
&self,
name: &str,
link: &str,
tags: &[&str],
user_id: i64,
) -> Result<database::Technology, Error> {
let tech = database::Technology {
_id: Uuid::new_v4(),
link: link.into(),
name: name.into(),
tags: tags.iter().map(|s| s.to_string()).collect(),
user_id,
created_at: chrono::Utc::now().timestamp().to_string(),
updated_at: None,
};
self.db
.collection::<database::Technology>("technologies")
.insert_one(&tech)
.map_err(|err| Error::new(ErrorKind::InvalidInput, err))?;
Ok(tech)
}
pub fn remove_tech(&self, id: Uuid) -> Result<(), Error> {
self.db
.collection::<database::Technology>("technologies")
.delete_one(doc! { "_id": id.to_string() })
.map_err(|err| Error::new(ErrorKind::InvalidInput, err))?;
Ok(())
}
pub fn bulk_remove_technologies(&self, ids: Vec<Uuid>) -> Result<(), Error> {
let ids = ids.iter().map(|id| id.to_string()).collect::<Vec<String>>();
self.db
.collection::<database::Technology>("technologies")
.delete_many(doc! {
"_id": {
"$in": ids
}
})
.map_err(|err| Error::new(ErrorKind::InvalidInput, err))?;
Ok(())
}
pub fn list_tech(&self) -> Result<Vec<database::Technology>, Error> {
Ok(self
.db
.collection("technologies")
.find(None)
.map_err(|err| Error::new(ErrorKind::InvalidInput, err))?
.filter(|doc| doc.is_ok())
.map(|doc| doc.unwrap())
.collect())
}
pub fn search_tech(
&self,
name: String,
options: String,
tags: &[&str],
) -> Result<Vec<database::Technology>, Error> {
let mut query = doc! {};
if !name.is_empty() {
query.insert(
"name",
doc! {
"$regex": Regex {
pattern: name,
options: options,
}
},
);
}
if !tags.is_empty() {
query.insert(
"tags",
doc! {
"$in": tags
},
);
}
Ok(self
.db
.collection::<database::Technology>("technologies")
.find(query)
.map_err(|err| Error::new(ErrorKind::InvalidInput, err))?
.map(
|doc| doc.unwrap(), // todo: find a way to handle error
)
.collect::<Vec<database::Technology>>())
}
pub fn edit_tech(
&self,
id: Uuid,
name: Option<String>,
link: Option<String>,
tags: Option<Vec<String>>,
) -> Result<(), Error> {
let mut update_doc = doc! {};
update_doc.insert("updated_at", chrono::Utc::now().timestamp().to_string());
if let Some(name) = name {
update_doc.insert("name", name);
}
if let Some(link) = link {
update_doc.insert("link", link);
}
if let Some(tags) = tags {
update_doc.insert("tags", tags);
}
self.db
.collection::<database::Technology>("technologies")
.update_one(
doc! {
"_id": id.to_string()
},
doc! {
"$set": update_doc
},
)
.map_err(|err| Error::new(ErrorKind::InvalidInput, err))?;
Ok(())
}
}
fn map_fielderror(err: Error) -> FieldError {
let err_str = err.to_string();
match err.kind() {
ErrorKind::NotFound => FieldError::new("not found", graphql_value!({ "error": err_str })),
ErrorKind::PermissionDenied => {
FieldError::new("permission denied", graphql_value!({ "error": err_str }))
}
ErrorKind::InvalidData => {
FieldError::new("invalid data", graphql_value!({ "error": err_str }))
}
ErrorKind::Unsupported => FieldError::new(
"unsupported operation",
graphql_value!({ "error": err_str }),
),
ErrorKind::Other => FieldError::new(
"failed to search technology",
graphql_value!({ "error": err_str }),
),
_ => FieldError::new("internal error", graphql_value!({ "error": err_str })),
}
}

78
database/src/main.rs Normal file
View File

@ -0,0 +1,78 @@
use std::{
io,
sync::{Arc, OnceLock},
};
use actix_cors::Cors;
use actix_web::{
get,
middleware::{self, Logger},
route,
web::{Data, Json},
App, HttpResponse, HttpServer, Responder,
};
use juniper::{
http::{graphiql::graphiql_source, GraphQLRequest},
EmptySubscription,
};
use crate::graphql::{MutationRoot, QueryRoot, Schema, DB};
mod graphql;
mod schema;
/// GraphiQL playground UI
#[get("/graphiql")]
async fn graphql_playground() -> impl Responder {
HttpResponse::Ok()
.content_type("text/html; charset=utf-8")
.body(graphiql_source("/graphql", None))
}
// GraphQL endpoint
#[route("/graphql", method = "GET", method = "POST")]
async fn graphql_handler_route(st: Data<Schema>, data: Json<GraphQLRequest>) -> impl Responder {
let user = data.execute(&st, CONTEXT.get().unwrap()).await;
HttpResponse::Ok().json(user)
}
static CONTEXT: OnceLock<DB> = OnceLock::new();
#[actix_web::main]
async fn main() -> io::Result<()> {
match dotenvy::from_path(std::env::var("DOTENV_PATH").unwrap_or_else(|_| "/.env".to_string())) {
Ok(_) => {}
Err(e) => {
println!("Failed to load .env file: {}", e);
}
}
env_logger::init_from_env(env_logger::Env::new().default_filter_or("info"));
// Create Juniper schema
let schema = Arc::new(Schema::new(
QueryRoot,
MutationRoot,
EmptySubscription::new(),
));
CONTEXT.get_or_init(|| DB::new());
HttpServer::new(move || {
App::new()
.wrap(Logger::default())
.app_data(Data::from(schema.clone()))
.service(graphql_handler_route)
.service(graphql_playground)
.wrap(Cors::permissive())
.wrap(middleware::Logger::default())
})
.bind((
std::env::var("HOST").unwrap_or(String::from("127.0.0.1")),
std::env::var("PORT")
.unwrap_or(String::from("8080"))
.parse::<u16>()
.expect("failed to convert port to u16"),
))?
.run()
.await
}

View File

@ -0,0 +1,29 @@
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use super::query;
#[derive(Serialize, Deserialize)]
pub struct Technology {
pub _id: Uuid,
pub link: String,
pub name: String,
pub tags: Vec<String>,
pub user_id: i64,
pub created_at: String, // can't use chrono::NaiveDateTime bc it fails to deserialize
pub updated_at: Option<String>, // can't use chrono::NaiveDateTime bc it fails to deserialize
}
impl Into<query::Technology> for Technology {
fn into(self) -> query::Technology {
query::Technology {
_id: self._id,
link: self.link,
name: self.name,
tags: self.tags,
user_id: self.user_id.to_string(),
created_at: self.created_at,
updated_at: self.updated_at,
}
}
}

View File

@ -0,0 +1,3 @@
pub mod database;
pub mod mutation;
pub mod query;

View File

@ -0,0 +1,14 @@
use juniper::GraphQLInputObject;
#[derive(GraphQLInputObject)]
#[graphql(description = "New technology to add to database")]
pub struct Technology {
#[graphql(description = "Name of technology")]
pub name: String,
#[graphql(description = "Link to technology")]
pub link: String,
#[graphql(description = "Technology tags (e.g. 'javascript', 'react')")]
pub tags: Vec<String>,
#[graphql(description = "user's Discord ID")]
pub user_id: f64,
}

View File

@ -0,0 +1,22 @@
use juniper::GraphQLObject;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Serialize, Deserialize, GraphQLObject)]
#[graphql(description = "Technology found in database")]
pub struct Technology {
#[graphql(description = "Technology's unique ID")]
pub _id: Uuid,
#[graphql(description = "Link to technology")]
pub link: String,
#[graphql(description = "Name of technology")]
pub name: String,
#[graphql(description = "Technology tags (e.g. 'javascript', 'react')")]
pub tags: Vec<String>,
#[graphql(description = "user's Discord ID")]
pub user_id: String,
#[graphql(description = "Technology creation timestamp")]
pub created_at: String,
#[graphql(description = "Technology update timestamp")]
pub updated_at: Option<String>,
}

13
frontend/.eslintignore Normal file
View File

@ -0,0 +1,13 @@
.DS_Store
node_modules
/build
/.svelte-kit
/package
.env
.env.*
!.env.example
# Ignore files for PNPM, NPM and YARN
pnpm-lock.yaml
package-lock.json
yarn.lock

30
frontend/.eslintrc.cjs Normal file
View File

@ -0,0 +1,30 @@
module.exports = {
root: true,
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:svelte/recommended',
'prettier'
],
parser: '@typescript-eslint/parser',
plugins: ['@typescript-eslint'],
parserOptions: {
sourceType: 'module',
ecmaVersion: 2020,
extraFileExtensions: ['.svelte']
},
env: {
browser: true,
es2017: true,
node: true
},
overrides: [
{
files: ['*.svelte'],
parser: 'svelte-eslint-parser',
parserOptions: {
parser: '@typescript-eslint/parser'
}
}
]
};

10
frontend/.gitignore vendored Normal file
View File

@ -0,0 +1,10 @@
.DS_Store
node_modules
/build
/.svelte-kit
/package
.env
.env.*
!.env.example
vite.config.js.timestamp-*
vite.config.ts.timestamp-*

2
frontend/.npmrc Normal file
View File

@ -0,0 +1,2 @@
engine-strict=true
resolution-mode=highest

13
frontend/.prettierignore Normal file
View File

@ -0,0 +1,13 @@
.DS_Store
node_modules
/build
/.svelte-kit
/package
.env
.env.*
!.env.example
# Ignore files for PNPM, NPM and YARN
pnpm-lock.yaml
package-lock.json
yarn.lock

9
frontend/.prettierrc Normal file
View File

@ -0,0 +1,9 @@
{
"useTabs": true,
"singleQuote": true,
"trailingComma": "none",
"printWidth": 100,
"plugins": ["prettier-plugin-svelte"],
"pluginSearchDirs": ["."],
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
}

48
frontend/package.json Normal file
View File

@ -0,0 +1,48 @@
{
"name": "frontend",
"version": "0.0.1",
"private": true,
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --plugin-search-dir . --check . && eslint .",
"format": "prettier --plugin-search-dir . --write ."
},
"devDependencies": {
"@iconify/svelte": "^3.1.4",
"@sveltejs/adapter-auto": "^2.0.0",
"@sveltejs/adapter-node": "^1.3.1",
"@sveltejs/kit": "^1.20.4",
"@tailwindcss/forms": "^0.5.4",
"@tailwindcss/typography": "^0.5.9",
"@types/chart.js": "^2.9.37",
"@types/uuid": "^9.0.2",
"@typescript-eslint/eslint-plugin": "^5.45.0",
"@typescript-eslint/parser": "^5.45.0",
"@urql/svelte": "^4.0.3",
"autoprefixer": "^10.4.14",
"chart.js": "^4.3.0",
"daisyui": "^3.2.1",
"dotenv": "^16.3.1",
"eslint": "^8.28.0",
"eslint-config-prettier": "^8.5.0",
"eslint-plugin-svelte": "^2.30.0",
"oauth4webapi": "^2.3.0",
"postcss": "^8.4.26",
"prettier": "^2.8.0",
"prettier-plugin-svelte": "^2.10.1",
"svelte": "^4.0.5",
"svelte-check": "^3.4.3",
"sveltekit-superforms": "^1.5.0",
"tailwindcss": "^3.3.3",
"tslib": "^2.4.1",
"typescript": "^5.0.0",
"uuid": "^9.0.0",
"vite": "^4.4.2",
"zod": "^3.21.4"
},
"type": "module"
}

2599
frontend/pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

3
frontend/src/app.css Normal file
View File

@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

18
frontend/src/app.d.ts vendored Normal file
View File

@ -0,0 +1,18 @@
// See https://kit.svelte.dev/docs/types#app
// for information about these interfaces
declare global {
namespace App {
// interface Error {
// status: number;
// message: string;
// }
interface Locals {
session: boolean;
}
// interface PageData {}
// interface Platform {}
}
}
export {};

12
frontend/src/app.html Normal file
View File

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover" data-theme="light">
%sveltekit.body%
</body>
</html>

View File

@ -0,0 +1,125 @@
import { env } from '$env/dynamic/private';
import type { OAuth2Response } from '$lib/types';
import { error, redirect, type Handle } from '@sveltejs/kit';
import { sequence } from '@sveltejs/kit/hooks';
const protectedRoutes = ['/'];
const auth: Handle = async ({ resolve, event }) => {
const refreshToken = event.cookies.get('refresh-token');
const accessToken = event.cookies.get('access-token');
if (!accessToken && refreshToken) {
const rsp = await fetch('https://discord.com/api/oauth2/token', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: new URLSearchParams({
client_id: env.CLIENT_ID,
client_secret: env.CLIENT_SECRET,
grant_type: 'refresh_token',
refresh_token: refreshToken
})
});
if (!rsp.ok) {
console.error(`failed to refresh token: ${rsp.status}`);
event.cookies.delete('refresh-token');
throw redirect(303, '/login');
}
const { access_token, expires_in }: OAuth2Response = await rsp.json();
event.cookies.set('access-token', access_token, {
maxAge: expires_in,
expires: new Date(Date.now() + expires_in),
httpOnly: true,
sameSite: true,
path: '/'
});
}
// * grab the access token again, in case it was just refreshed
event.locals.session = !!(event.cookies.get('access-token') && refreshToken);
return await resolve(event);
};
const handleAuth: Handle = async ({ resolve, event }) => {
if (event.locals.session && event.url.pathname.includes('/auth')) throw redirect(303, '/');
else if (event.locals.session && event.url.pathname === '/logout') {
event.cookies.delete('access-token');
event.cookies.delete('refresh-token');
throw redirect(303, '/login');
} else if (event.locals.session) return await resolve(event);
if (event.url.origin !== env.ORIGIN) {
console.error(`invalid origin. ${event.url.origin}`);
throw error(403, 'invalid origin');
}
if (event.url.pathname === '/auth/discord') {
const params = new URLSearchParams({
client_id: env.CLIENT_ID,
redirect_uri: `${env.ORIGIN}/auth/callback/discord`,
response_type: 'code',
scope: 'identify'
});
throw redirect(302, `https://discord.com/api/oauth2/authorize?${params.toString()}`);
} else if (event.url.pathname === '/auth/callback/discord') {
const code = event.url.searchParams.get('code');
if (!code) {
console.error(`failed to get code in callback url: ${event.url}`);
throw redirect(303, '/login');
}
const rsp = await fetch('https://discord.com/api/oauth2/token', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: new URLSearchParams({
client_id: env.CLIENT_ID,
client_secret: env.CLIENT_SECRET,
grant_type: 'authorization_code',
redirect_uri: `${env.ORIGIN}/auth/callback/discord`,
code
})
});
if (!rsp.ok) throw redirect(303, '/login');
const { access_token, refresh_token, expires_in }: OAuth2Response = await rsp.json();
event.cookies.set('access-token', access_token, {
maxAge: expires_in,
expires: new Date(Date.now() + expires_in),
httpOnly: true,
sameSite: true,
path: '/'
});
event.cookies.set('refresh-token', refresh_token, {
maxAge: Date.now() + 60 * 60 * 24 * 30,
expires: new Date(Date.now() + 60 * 60 * 24 * 30),
httpOnly: true,
sameSite: true,
path: '/'
});
console.info('successfully authenticated user');
throw redirect(303, '/');
}
return await resolve(event);
};
const guard: Handle = async ({ resolve, event }) => {
if (protectedRoutes.includes(event.url.pathname) && !event.locals.session) {
console.warn(`authentication failed for: ${event.url.pathname}`);
throw redirect(303, '/login');
} else if (event.url.pathname === '/login' && event.locals.session) throw redirect(303, '/');
return await resolve(event);
};
export const handle: Handle = sequence(auth, guard, handleAuth);

View File

@ -0,0 +1,6 @@
<div class="dropdown dropdown-end">
<slot name="heading" />
<ul class="menu dropdown-content z-[1] p-2 shadow bg-base-100 rounded-box w-auto mt-4 text-black">
<slot name="content" />
</ul>
</div>

View File

@ -0,0 +1,41 @@
<script lang="ts">
import type { Writable } from 'svelte/store';
import type { ValidationErrors } from 'sveltekit-superforms';
import type { ZodObject } from 'zod';
export let value: string;
export let errors: Writable<ValidationErrors<ZodObject<Record<string, any>>>>;
export let name: string;
export let label: string;
export let options: {
selected: boolean;
disabled: boolean;
value: string;
text: string;
}[];
</script>
<div
class="form-control w-full max-w-xs {$errors[name] ? 'input-error' : ''}"
aria-invalid={$errors[name] ? 'true' : undefined}
>
<label for={name} class="label">
<span class="label-text">{label}</span>
</label>
<select class="select select-bordered" bind:value {name}>
{#each options as opt}
<option value={opt.value} selected={opt.selected} disabled={opt.disabled}>{opt.text}</option>
{/each}
<option disabled selected>Pick one</option>
<option>Star Wars</option>
<option>Harry Potter</option>
<option>Lord of the Rings</option>
<option>Planet of the Apes</option>
<option>Star Trek</option>
</select>
{#if $errors[name]}
<label for={name} class="label">
<span class="label-text-alt text-red-600">{$errors[name]}</span>
</label>
{/if}
</div>

View File

@ -0,0 +1,36 @@
<script lang="ts">
import type { Writable } from 'svelte/store';
import type { ValidationErrors } from 'sveltekit-superforms';
import type { ZodObject } from 'zod';
export let value: string;
export let errors: Writable<ValidationErrors<ZodObject<Record<string, any>>>>;
export let name: string;
export let label: string;
export let placeholder: string;
export let inputClasses: string = '';
export let showtopLabel = true;
export let disabled = false;
</script>
<div class="flex flex-col items-start">
{#if showtopLabel}
<label for={name} class="label">
<span class="label-text">{label}</span>
</label>
{/if}
<input
type="text"
{name}
class="input input-bordered max-w-xs w-full {inputClasses} {$errors[name] ? 'input-error' : ''}"
{placeholder}
aria-invalid={$errors[name] ? 'true' : undefined}
bind:value
{disabled}
/>
{#if $errors[name]}
<label for={name} class="label">
<span class="label-text-alt text-red-600">{$errors[name]}</span>
</label>
{/if}
</div>

View File

@ -0,0 +1,23 @@
<script lang="ts">
import { deleteToast, toasts } from '$lib/toast';
import Icon from '@iconify/svelte';
import { fade } from 'svelte/transition';
</script>
{#if $toasts}
<div class="toast toast-top toast-end z-50">
{#each Object.entries($toasts) as [id, toast]}
<div class="alert alert-{toast.type}" transition:fade>
<span>{toast.message}</span>
<button
class="btn btn-ghost w-fit"
on:click={() => {
deleteToast(id);
}}
>
<Icon icon="iwwa:delete" />
</button>
</div>
{/each}
</div>
{/if}

28
frontend/src/lib/toast.ts Normal file
View File

@ -0,0 +1,28 @@
import { writable } from 'svelte/store';
import { v4 as uuid } from 'uuid';
type Toast = {
type?: 'success' | 'error' | 'info';
message: string;
timeout?: number;
};
export const toasts = writable<{ [K: string]: Toast }>({});
export const toast = (toast: Toast) => {
if (typeof toast.type === 'undefined') toast.type = 'info';
if (typeof toast.timeout === 'undefined') toast.timeout = 5000;
const id = uuid();
toasts.update((v) => ({ ...v, [id]: toast }));
setTimeout(() => {
deleteToast(id);
}, toast.timeout);
};
export const deleteToast = (id: string) => {
toasts.update((v) => {
delete v[id];
return v;
});
};

62
frontend/src/lib/types.ts Normal file
View File

@ -0,0 +1,62 @@
import { z } from 'zod';
export const form = {
addTechnology: z.object({
name: z.string().min(2),
link: z.string().url('link must be an URL'),
tags: z
.string()
.regex(
/^((\w|\/|-|_)+|((\w|\/|-|_)+,)+\w+)$/gm,
'tags must be a comma separated list of words'
)
}),
searchTechnology: z.object({
regex: z.string(),
options: z.string().max(7),
tags: z
.optional(
z
.string()
.regex(
/^(?=\s*$)|^((\w|\/|-|_)+|((\w|\/|-|_)+,)+\w+)$/gm,
'tags must be a comma separated list of words'
)
)
.default('')
})
};
export type Technology = {
id: string;
name: string;
link: string;
tags: string[];
userId: string;
createdAt: string;
updatedAt: string;
};
export type OAuth2Response = {
access_token: string;
refresh_token: string;
expires_in: number;
token_type: string;
scope: string;
};
type clientData = {
clientID: string;
clientSecret: string;
};
export type RequestToken = {
grantType: 'authorization_code';
code: string;
redirectUri: string;
} & clientData;
export type RefreshToken = {
grantType: 'refresh_token';
refresh_token: string;
} & clientData;

View File

@ -0,0 +1,37 @@
<script lang="ts">
import { page } from '$app/stores';
import Icon from '@iconify/svelte';
</script>
<section class="bg-white dark:bg-gray-900">
<div class="container flex items-center h-full px-6 py-12 mx-auto">
<div class="flex flex-col items-center max-w-sm mx-auto text-center">
<p class="p-3 text-sm font-medium text-blue-500 rounded-full bg-blue-50 dark:bg-gray-800">
<Icon icon="material-symbols:error-outline" width="32" />
</p>
<h1 class="mt-3 text-2xl font-semibold text-gray-800 dark:text-white md:text-3xl">
{$page.status}
</h1>
<h1 class="mt-3 text-2xl font-semibold text-gray-800 dark:text-white md:text-3xl">
{$page.error?.message}
</h1>
<div class="flex items-center w-full mt-6 gap-x-3 shrink-0 sm:w-auto">
<button
class="flex items-center justify-center w-1/2 px-5 py-2 text-sm text-gray-700 transition-colors duration-200 bg-white border rounded-lg gap-x-2 sm:w-auto dark:hover:bg-gray-800 dark:bg-gray-900 hover:bg-gray-100 dark:text-gray-200 dark:border-gray-700"
>
<Icon icon="radix-icons:pin-left" />
Go back
</button>
<button
class="flex items-center justify-center w-1/2 px-5 py-2 text-sm text-white transition-colors duration-200 bg-blue-500 rounded-lg sm:w-auto hover:bg-blue-600 dark:hover:bg-blue-500 dark:bg-blue-600"
>
<Icon icon="clarity:home-line" class="mr-2" />
Take me home
</button>
</div>
</div>
</div>
</section>

View File

@ -0,0 +1,28 @@
import { toast } from '$lib/toast';
import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = async ({ locals: { session }, cookies }) => {
if (session) {
const rsp = await fetch('https://discord.com/api/v10/users/@me', {
headers: {
Authorization: `Bearer ${cookies.get('access-token')}`
}
});
if (!rsp.ok) {
console.log("failed to fetch user's information");
toast({ type: 'error', message: "failed to fetch user's information" });
return {};
}
const { id, username, avatar } = await rsp.json();
return {
id,
username,
avatar: `https://cdn.discordapp.com/avatars/${id}/${avatar}.png`
};
}
return {};
};

View File

@ -0,0 +1,59 @@
<script lang="ts">
import { env } from '$env/dynamic/public';
import Toast from '$lib/components/toast.svelte';
import Icon from '@iconify/svelte';
import { Client, cacheExchange, fetchExchange, setContextClient } from '@urql/svelte';
import '../app.css';
import type { PageData } from './$types';
export let data: PageData;
setContextClient(
new Client({
url: env.PUBLIC_GRAPHQL_ENDPOINT,
exchanges: [cacheExchange, fetchExchange]
})
);
</script>
<Toast />
<div class="flex flex-col min-h-screen justify-center items-center">
<div class="navbar bg-base-content text-white">
<div class="flex-1">
<a class="btn btn-ghost normal-case text-xl" href="/">tech-bot</a>
</div>
<div class="flex-1 justify-end space-x-5 mr-5">
{#if data.id && data.avatar && data.username}
<div class="tooltip tooltip-bottom tooltip-info" data-tip="Copy ID">
<button
class="btn btn-ghost p-0 normal-case"
on:click={() => navigator.clipboard.writeText(data.id)}
>
{data.username}
</button>
</div>
<div class="tooltip tooltip-bottom tooltip-info" data-tip="Logout">
<a href="/logout">
<Icon icon="material-symbols:logout" width="28" />
</a>
</div>
<div class="avatar">
<div class="w-8 rounded-full">
<img src={data.avatar} alt="user's avatar" />
</div>
</div>
{/if}
</div>
</div>
<div class="flex-grow flex items-center">
<slot />
</div>
<footer class="footer footer-center p-4 bg-base-300 text-base-content">
<div>
<p>Copyright © 2023 - Made by Antoine Langlois</p>
</div>
</footer>
</div>

View File

@ -0,0 +1,28 @@
import { form as formType } from '$lib/types';
import { fail } from '@sveltejs/kit';
import { superValidate } from 'sveltekit-superforms/server';
import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async () => {
return {
add: await superValidate(formType.addTechnology),
search: await superValidate(formType.searchTechnology)
};
};
export const actions: Actions = {
add: async ({ request }) => {
const form = await superValidate(request, formType.addTechnology);
if (!form.valid) return fail(400, { form });
return { form };
},
search: async ({ request }) => {
const form = await superValidate(request, formType.searchTechnology);
if (!form.valid) return fail(400, { form });
return { form };
}
};

View File

@ -0,0 +1,353 @@
<script lang="ts">
import { invalidateAll } from '$app/navigation';
import Text from '$lib/components/inputs/text.svelte';
import { toast } from '$lib/toast';
import type { Technology } from '$lib/types';
import { getContextClient, gql, mutationStore, queryStore } from '@urql/svelte';
import type { ChangeEventHandler } from 'svelte/elements';
import { writable } from 'svelte/store';
import { superForm } from 'sveltekit-superforms/client';
import type { PageData } from './$types';
export let data: PageData;
const client = getContextClient();
const ltsTechnologies = writable<Technology[]>([]);
const searchedTechnologies = writable<Technology[]>([]);
const tbd = writable<string[]>([]);
queryStore({
client: client,
query: gql`
query {
technologies {
id
name
link
tags
userId
createdAt
updatedAt
}
}
`
}).subscribe(({ error, data }) => {
if (error) {
toast({ message: error.message, type: 'error' });
console.log(error.message);
} else if (data) {
ltsTechnologies.set(data.technologies);
}
});
const clearAddForm = (e: Event) => {
e.preventDefault();
$addForm.name = '';
$addForm.link = '';
$addForm.tags = '';
};
const {
enhance: addEnhance,
form: addForm,
errors: addErrors
} = superForm(data.add, {
onError({ result }) {
toast({
message: result.error.message,
type: 'error'
});
},
onUpdated({ form }) {
if (form.valid) {
mutationStore({
client: client,
query: gql`
mutation ($name: String!, $link: String!, $tags: [String!]!, $userId: String!) {
createTechnology(name: $name, link: $link, tags: $tags, userId: $userId) {
id
name
link
tags
userId
createdAt
updatedAt
}
}
`,
variables: {
name: form.data.name,
link: form.data.link,
tags: form.data.tags.split(','),
userId: data.id
}
}).subscribe(({ error, data }) => {
if (error) {
toast({ message: error.message, type: 'error' });
console.log(error.message);
} else if (data) {
ltsTechnologies.update((v) => [data.createTechnology, ...v]);
$addForm.name = '';
$addForm.link = '';
$addForm.tags = '';
}
});
}
},
resetForm: true
});
const {
enhance: searchEnhance,
form: searchForm,
errors: searchErrors
} = superForm(data.search, {
onError({ result }) {
toast({
message: result.error.message,
type: 'error'
});
},
onUpdated({ form }) {
if (form.valid) {
queryStore({
client: client,
query: gql`
query ($name: String!, $options: String!, $tags: [String!]) {
technology(name: $name, options: $options, tags: $tags) {
id
name
link
tags
userId
createdAt
updatedAt
}
}
`,
variables: {
name: form.data.regex,
options: form.data.options,
tags:
form.data.tags.includes(',') || form.data.tags.length > 0
? form.data.tags.split(',')
: null
}
}).subscribe(({ error, data }) => {
if (error) {
toast({ message: error.message, type: 'error' });
console.log(error.message);
} else if (data) {
searchedTechnologies.set(data.technology);
}
});
}
}
});
const updateTBD = (id: string | 'all'): ChangeEventHandler<HTMLInputElement> => {
return (el) => {
if (el.target && (el.target as Record<string, any>).checked) {
if (id === 'all') tbd.set($searchedTechnologies.map((tech) => tech.id));
else tbd.update((v) => [...v, id]);
return;
}
tbd.update((v) => {
const i = v.indexOf(id);
if (i > -1) v.splice(i, 1);
return v;
});
};
};
const deleteTBD = () => {
mutationStore({
client,
query: gql`
mutation ($ids: [Uuid!]!) {
deleteTechnologies(ids: $ids)
}
`,
variables: {
ids: $tbd
}
}).subscribe(({ error }) => {
if (error) {
toast({ message: error.message, type: 'error' });
} else {
toast({ message: 'technologies deleted!', type: 'success' });
invalidateAll().catch((error) => toast({ message: error.message, type: 'error' }));
}
});
};
const formatDate = (date: Date) => {
return `${date.getDate()}/${date.getMonth()}/${date.getFullYear()} ${date.getHours()}:${date.getMinutes()}`;
};
</script>
<div class="flex flex-col items-center">
<h1 class="text-xl font-bold text-center">Latest added technologies</h1>
<div class="card w-11/12 bg-base-200 shadow-inner h-auto my-4">
<div class="card-body items-center text-center">
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th />
<th>ID</th>
<th>Name</th>
<th>Link</th>
<th>Tags</th>
<th>Created at</th>
<th>Updated at</th>
<th>User</th>
</tr>
</thead>
<tbody>
{#each $ltsTechnologies as tech, index}
<tr>
<th>{index + 1}</th>
<td>{tech.id}</td>
<td>{tech.name}</td>
<td>{tech.link}</td>
<td>{tech.tags}</td>
<td>{formatDate(new Date(Number(tech.createdAt) * 1000))}</td>
<td
>{tech.updatedAt
? formatDate(new Date(Number(tech.updatedAt) * 1000))
: tech.updatedAt}</td
>
<td>{tech.userId}</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
</div>
<h1 class="text-xl font-bold text-center">Add a technology</h1>
<div class="card w-11/12 bg-base-200 shadow-inner h-auto my-4">
<div class="card-body items-center text-center">
<form method="POST" action="?/add" use:addEnhance class="flex flex-col space-y-6">
<div class="flex flex-row space-x-6">
<Text
bind:value={$addForm.name}
errors={addErrors}
label="Name"
name="name"
placeholder="GitHub"
/>
<Text
bind:value={$addForm.link}
errors={addErrors}
label="Link"
name="link"
placeholder="https://github.com"
/>
<Text
bind:value={$addForm.tags}
errors={addErrors}
label="Tags"
name="tags"
placeholder="CI/CD,git"
/>
</div>
<div class="flex flex-row space-x-4 self-center">
<button class="btn btn-success w-fit">Add</button>
<button class="btn btn-error w-fit" on:click={clearAddForm}>Clear form</button>
</div>
</form>
</div>
</div>
<h1 class="text-xl font-bold text-center">Delete a technology</h1>
<div class="card w-11/12 bg-base-200 shadow-inner h-auto my-4">
<div class="card-body items-center text-center">
<form method="POST" action="?/search" use:searchEnhance class="flex flex-col space-y-6">
<div class="join self-center">
<Text
bind:value={$searchForm.regex}
errors={searchErrors}
label="Regular expression"
name="regex"
placeholder="github|etree"
inputClasses="join-item"
showtopLabel={false}
/>
<Text
bind:value={$searchForm.options}
errors={searchErrors}
label="Options"
name="options"
placeholder="valid: imsRUux"
inputClasses="join-item"
showtopLabel={false}
/>
<Text
bind:value={$searchForm.tags}
errors={searchErrors}
label="Tags"
name="tags"
placeholder="git,rust,library"
inputClasses="join-item"
showtopLabel={false}
/>
<button class="btn btn-success join-item">Search</button>
</div>
{#if $searchedTechnologies.length > 0}
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>
<label>
<input type="checkbox" class="checkbox" on:change={updateTBD('all')} />
</label>
</th>
<th>Name</th>
<th>Link</th>
<th>Tags</th>
<th>Created at</th>
<th>Updated at</th>
<th>User</th>
</tr>
</thead>
<tbody>
{#each $searchedTechnologies as tech}
<tr>
<th>
<label>
<input type="checkbox" class="checkbox" on:change={updateTBD(tech.id)} />
</label>
</th>
<td>{tech.name}</td>
<td>{tech.link}</td>
<td>{tech.tags}</td>
<td>{formatDate(new Date(Number(tech.createdAt) * 1000))}</td>
<td
>{tech.updatedAt
? formatDate(new Date(Number(tech.updatedAt) * 1000))
: tech.updatedAt}</td
>
<td>{tech.userId}</td>
</tr>
{/each}
</tbody>
</table>
</div>
<div class="flex flex-row self-center">
<button class="btn btn-error w-fit" on:click={deleteTBD}>Delete</button>
</div>
{/if}
</form>
</div>
</div>
</div>

View File

@ -0,0 +1,13 @@
<div class="flex flex-col items-center">
<div class="card w-96 bg-base-100 shadow-xl">
<figure class="px-10 pt-10">
<img src="/discord.bmp" alt="Discord" class="rounded-xl w-28" />
</figure>
<div class="card-body items-center text-center">
<h2 class="card-title">Sign in to access the dashboard</h2>
<div class="card-actions mt-4">
<a class="btn btn-primary" href="/auth/discord">Login with discord</a>
</div>
</div>
</div>
</div>

BIN
frontend/static/avatar.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
frontend/static/discord.bmp (Stored with Git LFS) Normal file

Binary file not shown.

BIN
frontend/static/favicon.png (Stored with Git LFS) Normal file

Binary file not shown.

12
frontend/svelte.config.js Normal file
View File

@ -0,0 +1,12 @@
import adapter from '@sveltejs/adapter-node';
import { vitePreprocess } from '@sveltejs/kit/vite';
/** @type {import('@sveltejs/kit').Config} */
const config = {
preprocess: vitePreprocess(),
kit: {
adapter: adapter()
}
};
export default config;

View File

@ -0,0 +1,16 @@
import forms from '@tailwindcss/forms';
import typography from '@tailwindcss/typography';
import daisyui from 'daisyui';
import type { Config } from 'tailwindcss';
export default {
content: ['./src/**/*.{ts,svelte,html}'],
theme: {
extend: {}
},
daisyui: {
themes: ['dark', 'light', 'cupcake']
},
plugins: [daisyui, forms, typography],
safelist: ['alert-success', 'alert-error', 'alert-info', 'input-error']
} satisfies Config;

17
frontend/tsconfig.json Normal file
View File

@ -0,0 +1,17 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true
}
// Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias
//
// If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
// from the referenced tsconfig.json - TypeScript does not merge them in
}

9
frontend/vite.config.ts Normal file
View File

@ -0,0 +1,9 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [sveltekit()],
optimizeDeps: {
exclude: ['@urql/svelte']
}
});

View File

@ -1,173 +0,0 @@
use poise::command;
use url::Url;
use crate::database::*;
const HELP_MESSAGE: &str = "
Hello fellow human! I am a bot that can help you adding new technologies to a git repository.
To add a new technology, just type:
```
/add <technology> <link>
```
To list all technologies, just type:
```
/list
```
To search for a technology, just type:
```
/search <technology>
```
To remove a technology, you need to have the permission to remote a tech from the list.
If so, just type:
```
/remove <technology>
```
To get help, just type:
```
/help
```
";
type Error = Box<dyn std::error::Error + Send + Sync>;
type Context<'a> = poise::Context<'a, MsgData, Error>;
pub struct MsgData {}
/// Show help for all commands
#[command(slash_command, prefix_command)]
pub async fn help(ctx: Context<'_>) -> Result<(), Error> {
ctx.say(HELP_MESSAGE).await?;
Ok(())
}
/// Add a new technology to the technologies list
#[command(slash_command, prefix_command)]
pub async fn add(
ctx: Context<'_>,
#[description = "Technology name"] technology: String,
#[description = "Git repository link"] link: String,
#[description = "Technology tags (comma separated)"] tags: Option<String>,
) -> Result<(), Error> {
if !Url::parse(&link).is_ok() {
ctx.say(format!("Link {link} is not a valid URL")).await?;
return Ok(());
}
let tags = tags.unwrap_or(String::new());
add_tech(
&link,
&technology,
&(&tags).split(",").collect::<Vec<&str>>(),
)?;
ctx.say(format!(
"Added {technology} with link {link} and tags [{tags}]"
))
.await?;
Ok(())
}
/// List all available technologies
#[command(slash_command, prefix_command)]
pub async fn list(ctx: Context<'_>) -> Result<(), Error> {
let techs = list_tech()?;
if techs.len() == 0 {
ctx.say("No technologies saved").await?;
return Ok(());
}
ctx.say(format!(
"Saved technologies: {}",
techs
.iter()
.map(|tech| format!("[{}]({}) **{}**", tech.name, tech.link, tech.tags.join(",")))
.collect::<Vec<String>>()
.join(", ")
))
.await?;
Ok(())
}
/// Search for a technology
#[command(slash_command, prefix_command)]
pub async fn search(
ctx: Context<'_>,
#[description = "Technology name (can be a regex string)"] technology: String,
#[description = "Regex options"] options: Option<String>,
#[description = "Technology tags (comma separated)"] tags: Option<String>,
) -> Result<(), Error> {
let found_techs = search_tech(
technology,
options.map_or(String::new(), |opts| opts),
&tags
.unwrap_or(String::new())
.split(",")
.collect::<Vec<&str>>(),
)?;
if found_techs.len() == 0 {
ctx.say("No technologies found").await?;
return Ok(());
}
ctx.say(format!(
"Found technologies: {}",
found_techs
.iter()
.map(|tech| format!("Name: {} => Link: {}", tech.name, tech.link))
.collect::<Vec<String>>()
.join("\n")
))
.await?;
Ok(())
}
/// Remove a technology from the technologies list
#[command(slash_command, prefix_command)]
pub async fn remove(
ctx: Context<'_>,
#[description = "Technology name"] technology: String,
) -> Result<(), Error> {
if is_auth_user(ctx.author().id.to_string())? {
ctx.say("You don't have permission to remove a technology")
.await?;
return Ok(());
}
remove_tech(technology)?;
ctx.say("Technology removed").await?;
Ok(())
}
/// Remove a technology from the technologies list
#[command(slash_command, prefix_command)]
pub async fn add_auth_user(
ctx: Context<'_>,
#[description = "Discord user ID"] id: String,
) -> Result<(), Error> {
if !std::env::var("ADMIN_USERS")
.unwrap_or(String::new())
.contains(&ctx.author().id.to_string())
{
ctx.say("You don't have permission to add a new user")
.await?;
return Ok(());
}
set_auth_user(id)?;
ctx.say("User added!").await?;
Ok(())
}

View File

@ -1,96 +0,0 @@
use once_cell::sync::OnceCell;
use polodb_core::{
bson::{doc, Regex},
Database,
};
use serde::{Deserialize, Serialize};
use std::io::{Error, ErrorKind};
pub static DB: OnceCell<Database> = OnceCell::new();
#[derive(Default, Debug, Serialize, Deserialize)]
pub struct Technology {
pub link: String,
pub name: String,
pub tags: Vec<String>,
}
#[derive(Debug, Serialize, Deserialize)]
struct AuthorizedUser {
discord_id: String,
}
/// Add a new technology to the database.
pub fn add_tech(name: &str, link: &str, tags: &[&str]) -> Result<(), Error> {
DB.get()
.unwrap()
.collection::<Technology>("technologies")
.insert_one(Technology {
link: link.into(),
name: name.into(),
tags: tags.iter().map(|s| s.to_string()).collect(),
})
.map_err(|err| Error::new(ErrorKind::InvalidInput, err))?;
Ok(())
}
pub fn remove_tech(name: String) -> Result<(), Error> {
DB.get()
.unwrap()
.collection::<Technology>("technologies")
.delete_one(doc! { "name": name })
.map_err(|err| Error::new(ErrorKind::InvalidInput, err))?;
Ok(())
}
pub fn list_tech() -> Result<Vec<Technology>, Error> {
Ok(DB
.get()
.unwrap()
.collection("technologies")
.find(None)
.map_err(|err| Error::new(ErrorKind::InvalidInput, err))?
.filter(|doc| doc.is_ok())
.map(|doc| doc.unwrap())
.collect())
}
pub fn search_tech(name: String, options: String, tags: &[&str]) -> Result<Vec<Technology>, Error> {
Ok(DB
.get()
.unwrap()
.collection::<Technology>("technologies")
.find(doc! { "name": {"$regex": Regex {
pattern: name,
options: options,
}}, "tags": {
"$in": tags
} })
.map_err(|err| Error::new(ErrorKind::InvalidInput, err))?
.map(
|doc| doc.unwrap(), // todo: find a way to handle error
)
.collect::<Vec<Technology>>())
}
pub fn set_auth_user(discord_id: String) -> Result<(), Error> {
DB.get()
.unwrap()
.collection("authorized_users")
.insert_one(AuthorizedUser { discord_id })
.map_err(|err| Error::new(ErrorKind::InvalidInput, err))?;
Ok(())
}
pub fn is_auth_user(discord_id: String) -> Result<bool, Error> {
Ok(DB
.get()
.unwrap()
.collection::<AuthorizedUser>("authorized_users")
.find_one(doc! { "discord_id": discord_id })
.map_err(|err| Error::new(ErrorKind::InvalidInput, err))?
.is_some())
}