Compare commits

...

93 Commits

Author SHA1 Message Date
Preston Baxter 98bebfaafe add test cred id 2023-11-24 12:36:40 -06:00
Preston Baxter c1de3cd2b9 C: Add images to user guide 2023-11-24 12:02:59 -06:00
Preston Baxter 0e39465d81 C: Add User Guide 2023-11-24 11:49:54 -06:00
Preston Baxter d2b9436c04 C: Create Maintenece User Guide 2023-11-24 11:10:23 -06:00
Preston Baxter 916498b474 B: Its time to submit 2023-11-24 10:03:02 -06:00
Preston Baxter f0bdb07248 interim commit 2023-11-24 08:57:35 -06:00
Preston Baxter a2fde64459 update code.png 2023-11-24 02:06:42 -06:00
Preston Baxter 8da136dc34 formatting 2023-11-24 02:02:03 -06:00
Preston Baxter 1ba7327742 B: Run through testing one more time. Changes reflect test results 2023-11-24 02:01:38 -06:00
Preston Baxter d632f714d0 B: Add search and greatly clean up UI 2023-11-24 01:00:28 -06:00
Preston Baxter cf1c32208e Deploy and update picture 2023-11-23 14:11:50 -06:00
Preston Baxter 16a236aba1 B: Add getting metrics for cards 2023-11-23 14:07:57 -06:00
Preston Baxter 9c70466f30 B: move contents of /var/capstone/dist 2023-11-23 12:40:50 -06:00
Preston Baxter 9ad4170b82 formatting and update image 2023-11-23 12:22:16 -06:00
Preston Baxter 5f967bcde1 fix bug in deletions 2023-11-23 12:16:11 -06:00
Preston Baxter 523994ed26 B: IT WORKED 2023-11-23 12:15:26 -06:00
Preston Baxter 515bfd5ae5 B: Big changes for scheduling and handling broadcasts 2023-11-23 11:17:20 -06:00
Preston Baxter b5967c8e8c revert B: Move to webhook_subscriptions 2023-11-23 09:47:09 -06:00
Preston Baxter 2759c78061 version bumps 2023-11-23 09:46:28 -06:00
Preston Baxter a33c58d0d8 B: Move to webhook_subscription 2023-11-23 09:25:31 -06:00
Preston Baxter b77f45ccf6 B: More updates related to getting scheduling to work 2023-11-23 09:09:18 -06:00
Preston Baxter a1eecdd7f8 B: Add some more tests and better time finding 2023-11-23 08:24:30 -06:00
Preston Baxter f430e33a28 B: Several Silly fixes for silly mistakes 2023-11-23 08:05:44 -06:00
Preston Baxter e0f0bb3b5f B: Frontend works as expected now 2023-11-22 17:47:13 -06:00
Preston Baxter fdc2b3ab1b B: add response message to error logging 2023-11-21 19:58:38 -06:00
Preston Baxter c6ae07ccf7 B: NEVER FORGET RFC TAG 2023-11-21 19:45:25 -06:00
Preston Baxter 374826b577 B: update webhooks to avoid 404 2023-11-21 19:39:48 -06:00
Preston Baxter 563b935fe7 B: disable colors in logging 2023-11-21 19:32:49 -06:00
Preston Baxter 746886fcd9 update jsonapi version 2023-11-21 19:15:15 -06:00
Preston Baxter eadfc6b56e B: Make callback URIs follow config 2023-11-21 18:23:08 -06:00
Preston Baxter 8b8320eeb1 B: making it work on gcp 2023-11-19 20:51:05 -06:00
Preston Baxter 36b39e78c8 B: Build with loopvar fix 2023-11-19 09:51:38 -06:00
Preston Baxter 8601297bb4 B: try new mongo opts 2023-11-19 09:44:02 -06:00
Preston Baxter 6f3ec2375b B: adding deployment things 2023-11-19 09:37:45 -06:00
Preston Baxter 4c5f29c0b9 update code image 2023-11-18 20:17:50 -06:00
Preston Baxter 46f9460a37 B: Formatting 2023-11-18 20:16:44 -06:00
Preston Baxter 920e203b9d B: Ready for first deploy and validation testing 2023-11-18 20:15:15 -06:00
Preston Baxter d78ca20541 B: Dynamically get Auth Secret 2023-11-18 18:17:14 -06:00
Preston Baxter ebd193ab38 B: Fix type being wrong 2023-11-18 18:15:18 -06:00
Preston Baxter 3049da7bcd B: add method to find webhooks 2023-11-18 17:57:31 -06:00
Preston Baxter 9bc2e8d758 B: Add bson mapping to subscription 2023-11-18 17:54:35 -06:00
Preston Baxter 3e4257cba3 B: Add method to retrieve audit list 2023-11-18 17:44:21 -06:00
Preston Baxter 113a0e9287 B: Add PCO webhooks functionality 2023-11-18 17:15:13 -06:00
Preston Baxter 7adc41a260 update jsonapi head 2023-11-18 16:40:13 -06:00
Preston Baxter 2f3c293aad update code.png 2023-11-16 22:07:10 -06:00
Preston Baxter 6f308e5e23 B: Working on jsonapi things 2023-11-16 22:05:42 -06:00
Preston Baxter 6f1ce7dcc1 B: gofmt 2023-11-16 19:45:48 -06:00
Preston Baxter f703f2d1ab B: implement audit trail saving 2023-11-16 19:43:34 -06:00
Preston Baxter 1ecc00a86e B: Adjust audit trail 2023-11-16 19:23:50 -06:00
Preston Baxter 5ea803b67f cleanup 2023-11-16 19:15:47 -06:00
Preston Baxter 9c9c419055 B: added audit trail events. 2023-11-16 19:15:44 -06:00
Preston Baxter 8e58dfafa5 B: Added functionality to schedule broadcasts from new plan created 2023-11-16 19:15:30 -06:00
Preston Baxter f678a738b4 B: PCO Client now takes token source 2023-11-16 12:00:28 -06:00
Preston Baxter 61aacac37c B: Start youtube scheduling work 2023-11-14 13:13:26 -06:00
Preston Baxter abac8d7822 B: Some more comments 2023-11-14 12:58:08 -06:00
Preston Baxter 4079ff16a6 update oauth 2023-11-14 12:56:21 -06:00
Preston Baxter f20f622335 B: updates 2023-11-14 12:48:12 -06:00
Preston Baxter d3bd82c4f0 remove oauth 2023-11-14 12:47:47 -06:00
Preston Baxter aee35ac9d4 update image and makefile 2023-11-12 21:24:21 -06:00
Preston Baxter 77b000f9a3 B: Mongo backed token source is test passing 2023-11-12 20:34:20 -06:00
Preston Baxter 712791d297 B: Create mongodb backed oauth2 token source 2023-11-12 19:01:58 -06:00
Preston Baxter 360163f2dd B: Big squash
B: Trying tailwind things

update gitignore

B: Updates after moving machines

B: Action Skeleton

B: Add pco vendor to service directory. And tests

B: add extra pco structs

B: Catch up commit
2023-11-12 18:00:41 -06:00
Preston Baxter 96be01ea72 B: tmp 2023-11-05 11:34:53 -06:00
Preston Baxter 7ab96371c4 B: update makefile 2023-11-05 11:02:44 -06:00
Preston Baxter dff5fd149b B: Add cool image 2023-11-05 10:53:29 -06:00
Preston Baxter 175b6cae8c B: working on add action form 2023-11-04 22:43:01 -05:00
Preston Baxter 65167c3e58 B: Append last commit 2023-11-04 18:38:20 -05:00
Preston Baxter 5c7a72cf0f B: vendors working. How to make rules work 2023-11-04 17:52:48 -05:00
Preston Baxter d36eb955ab B: Redirect URI 2023-11-04 14:01:37 -05:00
Preston Baxter 4866071689 B: Redirect still whack. But closer 2023-11-03 19:00:14 -05:00
Preston Baxter 2c1d2d2520 B: Start youtube oauth flow and testing 2023-11-03 00:01:33 -05:00
Preston Baxter f58298d125 B: tmp 2 2023-11-02 20:06:35 -05:00
Preston Baxter 7ecfae9ee6 B: tmp 2023-11-02 18:26:45 -05:00
Preston Baxter cacf039ac4 B: use mongo_id instead of uuid 2023-11-02 18:23:07 -05:00
Preston Baxter ca70d241d6 B: Rework Model framework and finish vendor model so UI and callback controller can be built 2023-11-01 21:40:50 -05:00
Preston Baxter b1a573b840 B: verymuch in progress 2023-10-31 20:31:26 -05:00
Preston Baxter 4317b6b8c9 validate emails before searching in the DB 2023-10-30 19:11:15 -05:00
Preston Baxter aa0dd18e22 B: Thinking about vendors 2023-10-30 19:06:18 -05:00
Preston Baxter 791a00c695 B: break up navigation. 2023-10-30 18:44:01 -05:00
Preston Baxter a9d1d62df7 B: get dashboard to a more workable state 2023-10-29 20:20:59 -05:00
Preston Baxter f860da87ce B: login page tracks whether user is logged in or not 2023-10-29 18:10:32 -05:00
Preston Baxter d5f3f5e783 B: Make auth middleware redirect to login when token is invalid 2023-10-29 17:56:05 -05:00
Preston Baxter 344c17fb27 B: working login and logoout
Todo:
add validation to email and password to make sure bogus values aren't set
add password verifcation on signup
2023-10-28 18:25:51 -05:00
Preston Baxter 1bfcdce01a B: fix issues preventing logins from happening and add logging 2023-10-28 17:29:57 -05:00
Preston Baxter f7dc37fb02 B: getting closer to a login system 2023-10-28 16:50:44 -05:00
Preston Baxter ce0a301449 B: add db and prep for auth middleware 2023-10-28 13:42:29 -05:00
Preston Baxter db799805f9 B: get to login page 2023-10-28 11:34:11 -05:00
Preston Baxter 43d111cb0e landing page start 2023-10-28 10:15:42 -05:00
Preston Baxter ce2644a1ac remove template go files from git 2023-10-28 09:45:47 -05:00
Preston Baxter 19a0334a92 start the tmpl journey 2023-10-27 23:44:22 -05:00
Preston Baxter c20929b06f direct vpc access and domain mappings 2023-10-26 12:47:24 -05:00
Preston Baxter 010acc6d85 move to vpc 2023-10-26 11:35:53 -05:00
Preston Baxter 8e45bd11bb B: working skeleton 2023-10-23 21:01:09 -05:00
106 changed files with 10615 additions and 0 deletions

39
.gitignore vendored Normal file
View File

@ -0,0 +1,39 @@
infra/main.*.tfvars
# Canned terraform ignores#
infra/Local .terraform directories
**/.terraform/*
# .tfstate files
*.tfstate
*.tfstate.*
# Crash log files
infra/crash.log
infra/crash.*.log
# Exclude all .tfvars files, which are likely to contain sensitive data, such as
# password, private keys, and other secrets. These should not be part of version
# control as they are data points which are potentially sensitive and subject
# to change depending on the environment.
infra/*.tfvars
infra/*.tfvars.json
# Ignore override files as they are usually used to override resources locally and so
# are not checked in
infra/override.tf
infra/override.tf.json
infra/*_override.tf
infra/*_override.tf.json
# Include override files you do wish to add to version control using negated pattern
# !example_override.tf
# Include tfplan files to ignore the plan output of command: terraform plan -out=tfplan
# example: *tfplan*
# Ignore CLI configuration files
infra/.terraformrc
infra/terraform.rc
secrets/*

6
.gitmodules vendored Normal file
View File

@ -0,0 +1,6 @@
[submodule "libs/oauth2"]
path = libs/oauth2
url = https://git.preston-baxter.com/Preston_PLB/oauth2.git
[submodule "libs/jsonapi"]
path = libs/jsonapi
url = https://git.preston-baxter.com/Preston_PLB/jsonapi.git

40
Makefile Normal file
View File

@ -0,0 +1,40 @@
BASE_URL=us-central1-docker.pkg.dev/pbaxter-infra/capstone-repo
FRONTEND_VERSION=$(shell jq -rc ".frontend_version" versions.json)
WEBHOOK_VERSION=$(shell jq -rc ".webhook_version" versions.json)
deploy: deploy-ui deploy-service deploy-tf
deploy-tf:
cd infra; make deploy-yes
deploy-ui: build-ui
docker push $(BASE_URL)/frontend-service:latest
docker push $(BASE_URL)/frontend-service:$(FRONTEND_VERSION)
deploy-service: build-service
docker push $(BASE_URL)/webhook-service:latest
docker push $(BASE_URL)/webhook-service:$(WEBHOOK_VERSION)
build: build-ui build-service
build-ui:
docker build -f ./docker/ui.dockerfile . -t frontend-service:latest
docker build -f ./docker/ui.dockerfile . -t frontend-service:$(FRONTEND_VERSION)
docker tag frontend-service:latest $(BASE_URL)/frontend-service:latest
docker tag frontend-service:$(FRONTEND_VERSION) $(BASE_URL)/frontend-service:$(FRONTEND_VERSION)
build-service:
docker build -f ./docker/service.dockerfile . -t webhook-service:latest
docker build -f ./docker/service.dockerfile . -t webhook-service:$(WEBHOOK_VERSION)
docker tag webhook-service:latest $(BASE_URL)/webhook-service:latest
docker tag webhook-service:$(WEBHOOK_VERSION) $(BASE_URL)/webhook-service:$(WEBHOOK_VERSION)
image: SHELL := /bin/bash
image:
[[ -d "/tmp/capstone" ]] || mkdir /tmp/capstone
cp -R infra/ /tmp/capstone/
cp -R service/ /tmp/capstone/
cp -R ui/ /tmp/capstone/
rm -rf /tmp/capstone/ui/templates/*_templ.go
codevis -i /tmp/capstone --whitelist-extension go,hcl,tf,templ -o ./out.png
rm -rf /tmp/capstone/*

139
README.md
View File

@ -5,3 +5,142 @@
1. To meet and fufil the criteria of the WGU Software Engineering Capstone performance assessment.
1. To demonstrate my skills in designing and implementing complex systems
1. Build a service that fixes a problem my peers and I have
## Code Base
I like to keep track of what the codebase looks like
![picture](code.png)
# Requirements
Make sure you have isntalled:
- make
- jq
- GNU sed (If on macos `bew install gnu-sed` and change references to sed -> gsed)
- docker
- [OpenTofu](https://opentofu.org/) _open source terraform_
- go
Optional
- [codevis](https://github.com/sloganking/codevis) - _make the pretty picture_
- [air](https://github.com/cosmtrek/air) - hot reload
# How to run
## Infrastructure
infrastructure is deployed via terraform. It also makes some assumptions about the GCPenvironment its being deployed into.
Those main assumptions that it has the preston-baxter.com hosted zone. You may need to change this to make this terraform template work for You
### Terraform Variables
Contents of `terraform.tfvars`
```toml
project_id = "pbaxter-infra"
project_region = "us-central1"
webhook_service_tag = "0.2.1"
frontend_service_tag = "0.2.1"
```
You only need to specify the `project_id` and `project_region`
Both of the serice tags will be updated automatically via `make deploy`
### Run Terraform
You can either cd into the `infra/` direcrotry and run `make deploy`
or
You can run `make deploy-tf` from the root directory
## Services
The webook service is located in the `service/` directory
The frontend service is located in the `ui/` directory
Both services get ran the same way. What works will one will work for the other
### Config
Sample config
``` yaml
jwt_secret: some_random_string_that_is_long_but_not_too_long
env: test
mongo:
uri: "mongodb://localhost:27017"
ent_db: capstoneDB
ent_col: entities
lock_db: capstoneDB
lock_col: locks
app_settings:
webhook_service_url: localhost:8080
frontend_service_url: localhost:8080
vendors:
pco:
client_id: "test_client_id"
client_secret: "test_secret"
scopes:
- 'people'
- 'calendar'
- 'services'
auth_uri: "https://api.planningcenteronline.com/oauth/authorize"
token_uri: "https://api.planningcenteronline.com/oauth/token"
refresh_encode: json
youtube:
client_id: "test_client_id"
client_secret: "test_secret"
scopes:
- "https://www.googleapis.com/auth/youtube"
- "https://www.googleapis.com/auth/youtube.force-ssl"
- "https://www.googleapis.com/auth/youtube.download"
- "https://www.googleapis.com/auth/youtube.upload"
auth_uri: "https://accounts.google.com/o/oauth2/v2/auth"
token_uri: "https://oauth2.googleapis.com/token"
refresh_encode: url
test:
client_id: "client_id"
client_secret: "client_secret"
scopes:
- "scope 1"
- "scope 2"
auth_uri: "server/auth"
token_uri: "server/token"
refresh_encode: url
```
Config is expected at `/etc/capstone/config.yaml`
### Run service locally
Both services are configured with [air](https://github.com/cosmtrek/air). Air is a hot reload tool that speeds up the development process. It is particularly useful for working on the frontend service
To run locally, cd into the service directory and run `air`
### Make Docker Container
from the root directory
```bash
make build-service
make build-ui
```
### Run Docker Container Locally
```bash
docker run -p 8080:8080 -it webhook-service:latest
docker run -p 8080:8080 -it frontend-service:latest
```
# Deploy
Make suer versions are updated in `versions.json`
```bash
make deploy
```
Its that easy
NOTE: You may be asked to approve a change set.

BIN
code.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 464 KiB

3
docker/resolv.conf Normal file
View File

@ -0,0 +1,3 @@
nameserver 8.8.8.8
nameserver 8.8.4.4
nameserver 1.1.1.1

24
docker/service.dockerfile Normal file
View File

@ -0,0 +1,24 @@
FROM golang:1.21-alpine AS builder
WORKDIR /build/service
COPY service/go.mod service/go.sum ./
COPY ./ui ../ui
COPY ./libs ../libs
RUN go mod download
COPY ./service .
RUN GOEXPERIMENT=loopvar go build -o main
FROM amazonlinux:2023
COPY docker/resolv.conf /etc/resolv.conf
COPY --from=builder /build/service/main /bin/main
RUN mkdir -p /etc/capstone
COPY secrets/config.yaml /etc/capstone
EXPOSE "8080"
ENTRYPOINT ["/bin/main"]

43
docker/ui.dockerfile Normal file
View File

@ -0,0 +1,43 @@
#Build Go stuff
FROM golang:1.21-alpine AS builder
RUN go install github.com/a-h/templ/cmd/templ@latest
WORKDIR /build/ui
#Setup libs
COPY ui/go.mod ui/go.sum ./
COPY ./service ../service
COPY ./libs ../libs
RUN go mod download
COPY ui/ .
RUN rm **/*_templ.go; templ generate -path ./templates
RUN GOEXPERIMENT=loopvar go build -o main
#Build NPM stuff
FROM node:18-alpine AS node-builder
WORKDIR /build
COPY ui/ .
RUN npm install
RUN npx tailwindcss -i tailwind/index.css -o dist/output.css
#Final Contianer
FROM amazonlinux:2023
COPY docker/resolv.conf /etc/resolv.conf
RUN mkdir -p /var/capstone
COPY ui/static/ /var/capstone/dist
COPY --from=node-builder /build/dist /var/capstone/dist
COPY --from=builder /build/ui/main /bin/main
RUN mkdir -p /etc/capstone
COPY secrets/config.yaml /etc/capstone
EXPOSE "8080"
ENTRYPOINT ["/bin/main"]

BIN
docs/img/action_add.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

BIN
docs/img/dashboard.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

BIN
docs/img/events.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

BIN
docs/img/home.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

BIN
docs/img/login.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

BIN
docs/img/login_home.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

BIN
docs/img/signup.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

BIN
docs/img/vendor_add.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

44
docs/user_guide.md Normal file
View File

@ -0,0 +1,44 @@
# How to Use
This is your one stop shop for how to use my capstone project
# Logging In
to login click the login button in the top right of the homepage
![picture](./img/login_home.png)
or navigate to:
[https://capstone.preston-baxter.com/login](https://capstone.preston-baxter.com/login)
# Signing Up
If you don't have an account you can sign up by clicking `create an account`
![picture](./img/signup.png)
or at [https://capstone.preston-baxter.com/login](https://capstone.preston-baxter.com/login)
email must be in a valid format
# Adding Vendors
Once logged in on the dashboard page you can select the '+' button and the vendor account you would like to add.
![picture](./img/vendor_add.png)
You will then be redirected to that vendors login page to ask permission to act on your behalf.
# Creating Actions
Once you have some vendors configured you can then add an action. Currently the only action supported is scheduling a live stream off of a plan.
![picture](./img/action_add.png)
In the actions table, select the 'Add Action' button and follow the prompts.
# Auditing
Once you have an action setup you can view how the system is interacting with events and making descision in the events tab
![picture](./img/events.png)

20
infra/.terraform.lock.hcl Normal file
View File

@ -0,0 +1,20 @@
# This file is maintained automatically by "tofu init".
# Manual edits may be lost in future updates.
provider "registry.opentofu.org/hashicorp/google" {
version = "5.1.0"
constraints = ">= 3.3.0"
hashes = [
"h1:Mc09uV4QZR+1y2u4W/EO6HRmkiSCUoalXe6/tFfUWXA=",
"zh:08333cb1aedbc65137b40dbd3ef3aa9b0d5b4ef9acce156d11cc7b1f07f2804c",
"zh:0fb8b9a482e9d8f23ae6caadc1fd8811b15802be3c93b5ecbbd6efd49c62a8f2",
"zh:2d5f1dd805871c0490ac4dea783dbe85cd40f0f4fdbe9b2374a42b0a37a6f020",
"zh:6899593b6ae15bd8d8cc568d9664818c119d42da6c158c5869a94b8846cef733",
"zh:75dbb0cea493220e8c0e0c704d7ac2570121f2af582dfd205cd0f4749f8dcc79",
"zh:862b885016962ddce51f916db9a7d759f7bd136b4c29388187acd16298875eb7",
"zh:8f3804aaa5b8c1bbdaa6b43a02cc6f8d6f91595b8bf4eb588f9df1ea4fb00009",
"zh:a825327149dd2e85fc7cc22cc5466101f710d63f6320b2ccbd64362afabfff1b",
"zh:a926a757aa8f3b5b1147fcc3ec75cd55fb0d97b2165a83d075cded76f59e8dd9",
"zh:cd4d6bb663cac37633ae20d719ccd8ecc8f4cd983058c322404072ba9fabe232",
]
}

15
infra/Makefile Normal file
View File

@ -0,0 +1,15 @@
FRONTEND_VERSION=$(shell jq -rc ".frontend_version" ../versions.json)
WEBHOOK_VERSION=$(shell jq -rc ".webhook_version" ../versions.json)
replace: SHELL := /bin/bash
replace:
sed -i -Ee "s/(webhook_service_tag = \").*(\")/\1$(WEBHOOK_VERSION)\2/g" terraform.tfvars
sed -i -Ee "s/(frontend_service_tag = \").*(\")/\1$(FRONTEND_VERSION)\2/g" terraform.tfvars
deploy: SHELL := /bin/bash
deploy: replace
tofu apply
deploy-yes: SHELL := /bin/bash
deploy-yes: replace
tofu apply -auto-approve

160
infra/main.tf Normal file
View File

@ -0,0 +1,160 @@
terraform {
required_version = ">= 0.14"
required_providers {
google = ">= 3.3"
}
}
variable "project_id" {
description = "The GCP project ID where the infra will be built"
type = string
}
variable "project_region" {
description = "The GCP region where the infra will be built"
type = string
}
variable "webhook_service_tag" {
description = "Tag for the webhook service collector image"
type = string
}
variable "frontend_service_tag" {
description = "Tag for the frontend service collector image"
type = string
}
provider "google" {
project = var.project_id
}
resource "google_project_service" "run_api" {
service = "run.googleapis.com"
disable_on_destroy = true
}
resource "google_project_service" "artifact_api" {
service = "artifactregistry.googleapis.com"
disable_on_destroy = true
}
resource "google_project_service" "serverless_vpc_api" {
service = "vpcaccess.googleapis.com"
disable_on_destroy = true
}
resource "google_artifact_registry_repository" "capstone_repo" {
location = var.project_region
repository_id = "capstone-repo"
description = "Images for capstone project"
format = "DOCKER"
docker_config {
immutable_tags = false
}
depends_on = [ google_project_service.artifact_api ]
}
resource "google_cloud_run_v2_service" "webhook_service_cr" {
name = "webhook-service-cr"
location = var.project_region
template {
containers {
image = "${var.project_region}-docker.pkg.dev/${var.project_id}/${google_artifact_registry_repository.capstone_repo.name}/webhook-service:${var.webhook_service_tag}"
}
}
depends_on = [ google_project_service.run_api, google_artifact_registry_repository.capstone_repo ]
}
resource "google_cloud_run_v2_service_iam_member" "webhook_service_run_all_users" {
project = var.project_id
name = google_cloud_run_v2_service.webhook_service_cr.name
location = var.project_region
role = "roles/run.invoker"
member = "allUsers"
}
resource "google_cloud_run_v2_service" "frontend_service_cr" {
name = "frontend-service-cr"
location = var.project_region
template {
containers {
image = "${var.project_region}-docker.pkg.dev/${var.project_id}/${google_artifact_registry_repository.capstone_repo.name}/frontend-service:${var.frontend_service_tag}"
}
}
depends_on = [ google_project_service.run_api, google_artifact_registry_repository.capstone_repo ]
}
resource "google_cloud_run_v2_service_iam_member" "frontend_service_run_all_users" {
project = var.project_id
name = google_cloud_run_v2_service.frontend_service_cr.name
location = var.project_region
role = "roles/run.invoker"
member = "allUsers"
}
data "google_dns_managed_zone" "preston_baxter_zone" {
name = "pbaxter-main-zone"
}
resource "google_dns_record_set" "webhook_cname" {
name = "capstone-webhook.${data.google_dns_managed_zone.preston_baxter_zone.dns_name}"
managed_zone = data.google_dns_managed_zone.preston_baxter_zone.name
type = "CNAME"
ttl = 300
rrdatas = [
"ghs.googlehosted.com."
]
depends_on = [ google_cloud_run_v2_service.webhook_service_cr ]
}
resource "google_dns_record_set" "frontend_cname" {
name = "capstone.${data.google_dns_managed_zone.preston_baxter_zone.dns_name}"
managed_zone = data.google_dns_managed_zone.preston_baxter_zone.name
type = "CNAME"
ttl = 300
rrdatas = [
"ghs.googlehosted.com."
]
depends_on = [ google_cloud_run_v2_service.frontend_service_cr ]
}
resource "google_cloud_run_domain_mapping" "frontend_cname_mapping" {
location = "us-central1"
name = trimsuffix("capstone.${data.google_dns_managed_zone.preston_baxter_zone.dns_name}", ".")
metadata {
namespace = var.project_id
}
spec {
route_name = google_cloud_run_v2_service.frontend_service_cr.name
}
}
resource "google_cloud_run_domain_mapping" "webhook_cname_mapping" {
location = "us-central1"
name = trimsuffix("capstone-webhook.${data.google_dns_managed_zone.preston_baxter_zone.dns_name}", ".")
metadata {
namespace = var.project_id
}
spec {
route_name = google_cloud_run_v2_service.webhook_service_cr.name
}
}

1
libs/jsonapi Submodule

@ -0,0 +1 @@
Subproject commit 403bd2e40b5f46dd52413a0f298557b1bd7499e6

1
libs/oauth2 Submodule

@ -0,0 +1 @@
Subproject commit f088706217d0f51a8ed30f73791e3b3aebce5fda

46
service/.air.toml Normal file
View File

@ -0,0 +1,46 @@
root = "."
testdata_dir = "testdata"
tmp_dir = "tmp"
[build]
args_bin = []
bin = "./tmp/main"
cmd = "make local-build"
delay = 1000
exclude_dir = ["assets", "tmp", "vendor", "testdata", "dist", "docker", "node_modules"]
exclude_file = []
exclude_regex = ["_test.go", "_templ.go"]
exclude_unchanged = false
follow_symlink = false
full_bin = ""
include_dir = []
include_ext = ["go", "tpl", "tmpl", "html", "templ", "css"]
include_file = []
kill_delay = "0s"
log = "build-errors.log"
poll = false
poll_interval = 0
post_cmd = []
pre_cmd = []
rerun = false
rerun_delay = 500
send_interrupt = false
stop_on_error = false
[color]
app = ""
build = "yellow"
main = "magenta"
runner = "green"
watcher = "cyan"
[log]
main_only = false
time = false
[misc]
clean_on_exit = false
[screen]
clear_on_rebuild = false
keep_scroll = true

1
service/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
tmp/

12
service/Makefile Normal file
View File

@ -0,0 +1,12 @@
BASE_URL="us-central1-docker.pkg.dev/pbaxter-infra/capstone-repo"
local-build:
GOEXPERIMENT=loopvar go build -o ./tmp/main .
build:
docker build . -t webhook-service:latest
docker tag webhook-service:latest $(BASE_URL)/webhook-service:latest
deploy: build
docker push $(BASE_URL)/webhook-service:latest

View File

@ -0,0 +1,40 @@
package controllers
import (
"git.preston-baxter.com/Preston_PLB/capstone/frontend-service/config"
"git.preston-baxter.com/Preston_PLB/capstone/frontend-service/db"
"git.preston-baxter.com/Preston_PLB/capstone/webhook-service/vendors/pco"
"github.com/gin-gonic/gin"
"github.com/sirupsen/logrus"
"go.mongodb.org/mongo-driver/bson/primitive"
"google.golang.org/api/youtube/v3"
)
var (
log *logrus.Logger
mongo *db.DB
ytClientMap map[primitive.ObjectID]*youtube.Service
pcoClientMap map[primitive.ObjectID]*pco.PcoApiClient
)
func BuildRouter(r *gin.Engine) {
conf := config.Config()
log = logrus.New()
log.SetFormatter(&logrus.TextFormatter{
DisableColors: true,
})
log.SetLevel(logrus.DebugLevel)
var err error
mongo, err = db.NewClient(conf.Mongo.Uri)
if err != nil {
panic(err)
}
ytClientMap = make(map[primitive.ObjectID]*youtube.Service)
pcoClientMap = make(map[primitive.ObjectID]*pco.PcoApiClient)
pco := r.Group("/pco")
pco.POST("/:userid", ValidatePcoWebhook, ConsumePcoWebhook)
}

View File

@ -0,0 +1,88 @@
package controllers
import (
"bytes"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"git.preston-baxter.com/Preston_PLB/capstone/webhook-service/vendors/pco/webhooks"
"github.com/gin-gonic/gin"
"github.com/google/jsonapi"
)
const PCO_VALIDATE_HEADER = "X-PCO-Webhooks-Authenticity"
func ValidatePcoWebhook(c *gin.Context) {
//get remote version from header
remoteDigestStr := c.GetHeader(PCO_VALIDATE_HEADER)
if remoteDigestStr == "" {
log.Warnf("Request was sent with no %s header. Rejecting", PCO_VALIDATE_HEADER)
c.AbortWithStatus(401)
return
}
pcoSig := make([]byte, len(remoteDigestStr)/2)
_, err := hex.Decode(pcoSig, []byte(remoteDigestStr))
if err != nil {
log.WithError(err).Error("Failed to decode byte digest")
_ = c.AbortWithError(501, err)
return
}
//clone request body to harmlessly inspect the body
bodyCopy := bytes.NewBuffer([]byte{})
_, err = io.Copy(bodyCopy, c.Request.Body)
if err != nil {
log.WithError(err).Error("Failed to copy body while validating PCO webhook")
_ = c.AbortWithError(501, err)
return
}
body := bodyCopy.Bytes()
c.Request.Body = io.NopCloser(bytes.NewReader(body))
//Get secret
key, err := getAuthSecret(c, body)
if err != nil {
log.WithError(err).Error("Failed to find auth secret for event. It may not be setup")
_ = c.AbortWithError(501, err)
return
}
//Get HMAC
hmacSig := hmac.New(sha256.New, []byte(key))
hmacSig.Write(body)
if !hmac.Equal(hmacSig.Sum(nil), pcoSig) {
log.Warn("")
c.AbortWithStatus(401)
}
}
func getAuthSecret(c *gin.Context, body []byte) (string, error) {
userObjectId := userIdFromContext(c)
log.Debug(string(body))
//Pco is weird and sends a data array instead of an object. Yet there is only one event. Fun times
event, err := jsonapi.UnmarshalManyPayload[webhooks.EventDelivery](bytes.NewBuffer(body))
if err != nil {
return "", errors.Join(fmt.Errorf("Failed to unmarshall event delivery from PCO"), err)
}
if len(event) == 0 {
return "", fmt.Errorf("There are no events in the delivery. Something is wrong")
}
webhook, err := mongo.FindPcoSubscriptionForUser(*userObjectId, event[0].Name)
if err != nil {
return "", errors.Join(fmt.Errorf("Failed to find pco subscription for user: %s and event: %s", userObjectId.Hex(), event[0].Name), err)
}
if webhook == nil {
return "", fmt.Errorf("Could not find subscription for user: %s and name %s", userObjectId.Hex(), event[0].Name)
}
return webhook.Details.AuthenticitySecret, nil
}

View File

@ -0,0 +1,421 @@
package controllers
import (
"context"
"errors"
"fmt"
"regexp"
"sync"
"time"
"git.preston-baxter.com/Preston_PLB/capstone/frontend-service/config"
"git.preston-baxter.com/Preston_PLB/capstone/frontend-service/db"
"git.preston-baxter.com/Preston_PLB/capstone/frontend-service/db/models"
"git.preston-baxter.com/Preston_PLB/capstone/webhook-service/vendors/pco"
"git.preston-baxter.com/Preston_PLB/capstone/webhook-service/vendors/pco/services"
"git.preston-baxter.com/Preston_PLB/capstone/webhook-service/vendors/pco/webhooks"
yt_helpers "git.preston-baxter.com/Preston_PLB/capstone/webhook-service/vendors/youtube"
"github.com/gin-gonic/gin"
"github.com/google/jsonapi"
"go.mongodb.org/mongo-driver/bson/primitive"
"golang.org/x/oauth2"
"google.golang.org/api/option"
"google.golang.org/api/youtube/v3"
)
var (
eventRegexKeys = map[string]string{"plan": `^services\.v2\.events\.plan\..*`}
actionFuncMap = map[string]actionFunc{"youtube.livestream": ScheduleBroadcastFromWebhook}
//Error definintions
errorOkMap = map[error]bool{NotSchedulableTime: true, UnknownEventErr: true, AlreadyScheduledBroadcast: true, NoBroadcastToDelete: true}
NotSchedulableTime = errors.New("This time is not schedulable")
UnknownEventErr = errors.New("Event sent is unkown")
AlreadyScheduledBroadcast = errors.New("This broadcast has already been scheduled")
NoBroadcastToDelete = errors.New("No Broadcasts to destroy")
)
const (
CREATED_BROADCAST = "Created Broadcast"
UPDATED_BROADCAST = "Updated Broadcast"
DELETED_BROADCAST = "Deleted Broadcast"
)
type actionFunc func(*gin.Context, *webhooks.EventDelivery) error
func userIdFromContext(c *gin.Context) *primitive.ObjectID {
if id, ok := c.Get("user_bson_id"); !ok {
userId := c.Param("userid")
if userId == "" {
log.Warn("Webhook did not contain user id. Rejecting")
c.AbortWithStatus(404)
return nil
}
userObjectId, err := primitive.ObjectIDFromHex(userId)
if err != nil {
log.WithError(err).Warn("User Id was malformed")
c.AbortWithStatus(400)
return nil
}
c.Set("user_bson_id", userObjectId)
return &userObjectId
} else {
if objId, ok := id.(primitive.ObjectID); ok {
return &objId
} else {
return nil
}
}
}
func ConsumePcoWebhook(c *gin.Context) {
userObjectId := userIdFromContext(c)
//read body and handle io in parallel because IO shenanigains
wg := new(sync.WaitGroup)
wg.Add(2)
//get actions for user
var actionMappings []models.ActionMapping
var webhookBody *webhooks.EventDelivery
errs := make([]error, 2)
go func(wg *sync.WaitGroup) {
defer wg.Done()
actionMappings, errs[0] = mongo.FindActionMappingsByUser(*userObjectId)
}(wg)
go func(wg *sync.WaitGroup) {
defer wg.Done()
var payload []webhooks.EventDelivery
payload, errs[1] = jsonapi.UnmarshalManyPayload[webhooks.EventDelivery](c.Request.Body)
webhookBody = &payload[0]
}(wg)
wg.Wait()
if err := errors.Join(errs...); err != nil {
log.WithError(err).Errorf("Failed to do the IO parts")
_ = c.AbortWithError(501, err)
return
}
//perform actions
//loop through all actions a user has
for _, mapping := range actionMappings {
//find the ones that are runable by this function
if mapping.SourceEvent.VendorName == models.PCO_VENDOR_NAME && eventMatch(mapping.SourceEvent.Key, webhookBody.Name) {
//generate lookup key for function
actionKey := fmt.Sprintf("%s.%s", mapping.Action.VendorName, mapping.Action.Type)
//if function exists run the function
if action, ok := actionFuncMap[actionKey]; ok {
err := action(c, webhookBody)
//handle error
if err != nil {
//if err is in the ok map, return 200
if pass, ok := errorOkMap[err]; ok && pass {
log.Warnf("Continueing after error: %s. From action: %s. From event source: %s:%s", err, actionKey, mapping.SourceEvent.VendorName, mapping.SourceEvent.Key)
c.Status(200)
} else {
log.WithError(err).Errorf("Failed to execute action: %s. From event source: %s:%s", actionKey, mapping.SourceEvent.VendorName, mapping.SourceEvent.Key)
_ = c.AbortWithError(501, err)
}
} else {
log.Infof("Succesfully proccessed: %s for %s", webhookBody.Name, userObjectId.Hex())
c.Status(200)
}
return
}
}
}
log.Warnf("No errors, but also no work...")
c.Status(200)
}
func eventMatch(key, event string) bool {
if regexString, ok := eventRegexKeys[key]; ok {
reg := regexp.MustCompile(regexString) //TODO: Make this regex cache-able
return reg.MatchString(event)
} else {
return false
}
}
func pcoServiceForUser(userId primitive.ObjectID) (*pco.PcoApiClient, error) {
//add youtube client to map if its not there
if client, ok := pcoClientMap[userId]; !ok {
pcoAccount, err := mongo.FindVendorAccountByUser(userId, models.PCO_VENDOR_NAME)
if err != nil {
return nil, err
}
//Build our fancy token source
tokenSource := oauth2.ReuseTokenSource(pcoAccount.Token(), mongo.NewVendorTokenSource(pcoAccount))
//init service
conf := config.Config()
client := pco.NewClientWithOauthConfig(conf.Vendors[models.PCO_VENDOR_NAME].OauthConfig(), tokenSource)
//add user to map
pcoClientMap[userId] = client
return client, nil
} else {
return client, nil
}
}
func youtubeServiceForUser(userId primitive.ObjectID) (*youtube.Service, error) {
//add youtube client to map if its not there
if client, ok := ytClientMap[userId]; !ok {
ytAccount, err := mongo.FindVendorAccountByUser(userId, models.YOUTUBE_VENDOR_NAME)
if err != nil {
return nil, err
}
//Build our fancy token source
tokenSource := oauth2.ReuseTokenSource(ytAccount.Token(), mongo.NewVendorTokenSource(ytAccount))
//init service
client, err := youtube.NewService(context.Background(), option.WithTokenSource(tokenSource))
if err != nil {
log.WithError(err).Error("Failed to init youtube service")
return nil, err
}
//add user to map
ytClientMap[userId] = client
return client, nil
} else {
return client, nil
}
}
// TODO: Revisit the structure of this function
func ScheduleBroadcastFromWebhook(c *gin.Context, body *webhooks.EventDelivery) error {
//get uid from context. Lots of sanitizing just incase
uid := userIdFromContext(c)
//Check if this is a redilivery.
//Load ytClient for user. It is fetched from cache or created
ytClient, err := youtubeServiceForUser(*uid)
if err != nil {
log.WithError(err).Error("Failed to initialize youtube client")
return err
}
//Load pcoClient for user. It is fetched from cache or created
pcoClient, err := pcoServiceForUser(*uid)
if err != nil {
log.WithError(err).Error("Failed to initialize youtube client")
return err
}
//deserialize the payload
payload := &services.Plan{}
err = body.UnmarshallPayload(payload)
if err != nil {
log.WithError(err).Error("Failed to unmarshall body")
return err
}
//Save audit point
eventRecievedAudit := &models.EventRecieved{
UserId: *uid,
VendorName: models.PCO_VENDOR_NAME,
VendorId: body.ID,
CorrelationId: payload.Id,
Type: body.Name,
}
if err := mongo.SaveModel(eventRecievedAudit); err != nil {
log.WithError(err).WithField("EventRecieved", eventRecievedAudit).Error("Failed to save audit event. Logging here and resuming")
}
//Check to see if we have scheduled a broadcast befre
broadcasts, err := mongo.FindAllBroadcastsByCorrelationId(*uid, payload.Id)
if err != nil {
return errors.Join(fmt.Errorf("Failed to find broadcasts for user: %s and CorrelationId: %s", uid.Hex(), payload.Id), err)
}
var result string
if len(broadcasts) > 0 {
//What do we do when we have already scheduled the broadcast
switch body.Name {
//If we get plan created event for this, return already scheduled error
case "services.v2.events.plan.created":
return AlreadyScheduledBroadcast
//update the broadcast
case "services.v2.events.plan.updated":
//TODO: Update Broadcast
err := updateBroadcastFromWebhook(c, broadcasts, payload, ytClient, pcoClient)
if err != nil {
log.WithError(err).Error("Failed to update broadcast from updated event")
return err
}
result = UPDATED_BROADCAST
//delete the broadcast
case "services.v2.events.plan.destroyed":
//TODO: Delete broadcast
err := deleteBroadcastFromWebhook(c, broadcasts, payload, ytClient, pcoClient)
if err != nil {
log.WithError(err).Error("Failed to delete broadcast from updated event")
return err
}
result = DELETED_BROADCAST
default:
return UnknownEventErr
}
actionTaken := &models.ActionTaken{
UserId: *uid,
TriggeringEvent: eventRecievedAudit.MongoId(),
Result: result,
CorrelationId: payload.Id,
VendorName: models.YOUTUBE_VENDOR_NAME,
}
//save audit trail
err = mongo.SaveModels(actionTaken)
if err != nil {
log.WithError(err).Error("Failed to save broadcastModel and actionTaken")
return err
}
} else {
//No broadcast is scheduled
//create the broadcast
var broadcast *youtube.LiveBroadcast
switch body.Name {
case "services.v2.events.plan.created":
broadcast, err = scheduleNewBroadcastFromWebhook(c, payload, ytClient, pcoClient)
if err != nil {
log.WithError(err).Error("Failed to schedule broadcast from created event")
return err
}
result = CREATED_BROADCAST
case "services.v2.events.plan.updated":
broadcast, err = scheduleNewBroadcastFromWebhook(c, payload, ytClient, pcoClient)
if err != nil {
log.WithError(err).Error("Failed to schedule broadcast from updated event")
return err
}
result = CREATED_BROADCAST
case "services.v2.events.plan.destroyed":
return NoBroadcastToDelete
default:
return fmt.Errorf("Unkown event error: %s", body.Name)
}
//build audit trail after action was taken
broadcastModel := &models.YoutubeBroadcast{
UserId: *uid,
CorrelationId: payload.Id,
Details: broadcast,
}
actionTaken := &models.ActionTaken{
UserId: *uid,
TriggeringEvent: eventRecievedAudit.MongoId(),
Result: result,
CorrelationId: payload.Id,
VendorName: models.YOUTUBE_VENDOR_NAME,
}
//save audit trail
err = mongo.SaveModels(broadcastModel, actionTaken)
if err != nil {
log.WithError(err).Error("Failed to save broadcastModel and actionTaken")
return err
}
}
return nil
}
func scheduleNewBroadcastFromWebhook(c *gin.Context, plan *services.Plan, ytClient *youtube.Service, pcoClient *pco.PcoApiClient) (*youtube.LiveBroadcast, error) {
times, err := pcoClient.GetPlanTimes(plan.ServiceType.Id, plan.Id)
if err != nil {
return nil, err
}
startTime := times[0].StartsAt
// endTime := times[len(times) - 1].EndsAt TODO: this will be used later
//if starttime is before now, skip with a passable error
if startTime.Before(time.Now()) {
return nil, NotSchedulableTime
}
var title string
if plan.Title == "" {
title = "Live Stream Scheduled By Capstone"
} else {
title = plan.Title
}
log.Debugf("Trying to schedule time at: %s", startTime.Format(yt_helpers.ISO_8601))
return yt_helpers.InsertBroadcast(ytClient, title, startTime, yt_helpers.STATUS_PRIVATE)
}
func updateBroadcastFromWebhook(c *gin.Context, broadcasts []models.YoutubeBroadcast, plan *services.Plan, ytClient *youtube.Service, pcoClient *pco.PcoApiClient) error {
times, err := pcoClient.GetPlanTimes(plan.ServiceType.Id, plan.Id)
if err != nil {
return err
}
startTime := times[0].StartsAt
// endTime := times[len(times) - 1].EndsAt TODO: this will be used later
//if starttime is before now, skip with a passable error
if startTime.Before(time.Now()) {
return NotSchedulableTime
}
var title string
if plan.Title == "" {
title = "Live Stream Scheduled By Capstone"
} else {
title = plan.Title
}
//create list of errors to process all of the broadcasts and then error
errs := make([]error, 0, len(broadcasts))
bcs := make([]*models.YoutubeBroadcast, 0, len(broadcasts))
for index, broadcast := range broadcasts {
liveBroadcast, err := yt_helpers.UpdateBroadcast(ytClient, broadcast.Details.Id, title, startTime, yt_helpers.STATUS_PRIVATE)
if err != nil {
errs = append(errs, err)
} else {
broadcasts[index].Details = liveBroadcast
bcs = append(bcs, &broadcasts[index])
}
}
if err := errors.Join(errs...); err != nil {
return err
}
return db.SaveModelSlice(mongo, bcs...)
}
func deleteBroadcastFromWebhook(c *gin.Context, broadcasts []models.YoutubeBroadcast, plan *services.Plan, ytClient *youtube.Service, pcoClient *pco.PcoApiClient) error {
errs := make([]error, 0, len(broadcasts))
bcs := make([]*models.YoutubeBroadcast, 0, len(broadcasts))
for index, broadcast := range broadcasts {
err := yt_helpers.DeleteBroadcast(ytClient, broadcast.Details.Id)
if err != nil {
errs = append(errs, err)
} else {
bcs = append(bcs, &broadcasts[index])
}
}
if err := errors.Join(errs...); err != nil {
return err
}
return db.DeleteModelSlice(mongo, bcs...)
}

View File

@ -0,0 +1,15 @@
package controllers
import (
"testing"
"github.com/go-playground/assert/v2"
)
func TestPlanEventMatch(t *testing.T) {
events := []string{"services.v2.events.plan.updated", "services.v2.events.plan.destroyed", "services.v2.events.plan.created"}
for _, event := range events {
assert.Equal(t, eventMatch("plan", event), true)
}
}

88
service/go.mod Normal file
View File

@ -0,0 +1,88 @@
module git.preston-baxter.com/Preston_PLB/capstone/webhook-service
go 1.21
toolchain go1.21.4
require (
git.preston-baxter.com/Preston_PLB/capstone/frontend-service v0.0.0-00010101000000-000000000000
github.com/gin-gonic/gin v1.9.1
github.com/go-playground/assert/v2 v2.2.0
github.com/google/jsonapi v1.0.0
github.com/sirupsen/logrus v1.9.3
go.mongodb.org/mongo-driver v1.13.0
golang.org/x/oauth2 v0.14.0
google.golang.org/api v0.150.0
)
require (
cloud.google.com/go/compute v1.23.1 // indirect
cloud.google.com/go/compute/metadata v0.2.3 // indirect
github.com/bytedance/sonic v1.9.1 // indirect
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
github.com/fsnotify/fsnotify v1.6.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.14.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/golang/protobuf v1.5.3 // indirect
github.com/golang/snappy v0.0.4 // indirect
github.com/google/s2a-go v0.1.7 // indirect
github.com/google/uuid v1.4.0 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.2 // indirect
github.com/googleapis/gax-go/v2 v2.12.0 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.17.0 // indirect
github.com/klauspost/cpuid/v2 v2.2.4 // indirect
github.com/leodido/go-urn v1.2.4 // indirect
github.com/magiconair/properties v1.8.7 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect
github.com/miekg/dns v1.1.57 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe // indirect
github.com/pelletier/go-toml/v2 v2.1.0 // indirect
github.com/sagikazarmark/locafero v0.3.0 // indirect
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
github.com/spf13/afero v1.10.0 // indirect
github.com/spf13/cast v1.5.1 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/spf13/viper v1.17.0 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.11 // indirect
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
github.com/xdg-go/scram v1.1.2 // indirect
github.com/xdg-go/stringprep v1.0.4 // indirect
github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d // indirect
go.opencensus.io v0.24.0 // indirect
go.uber.org/atomic v1.10.0 // indirect
go.uber.org/multierr v1.9.0 // indirect
golang.org/x/arch v0.3.0 // indirect
golang.org/x/crypto v0.15.0 // indirect
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect
golang.org/x/mod v0.12.0 // indirect
golang.org/x/net v0.18.0 // indirect
golang.org/x/sync v0.5.0 // indirect
golang.org/x/sys v0.14.0 // indirect
golang.org/x/text v0.14.0 // indirect
golang.org/x/tools v0.13.0 // indirect
google.golang.org/appengine v1.6.7 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20231030173426-d783a09b4405 // indirect
google.golang.org/grpc v1.59.0 // indirect
google.golang.org/protobuf v1.31.0 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
replace git.preston-baxter.com/Preston_PLB/capstone/frontend-service => ../ui
replace golang.org/x/oauth2 => ../libs/oauth2
replace github.com/google/jsonapi => ../libs/jsonapi

1682
service/go.sum Normal file

File diff suppressed because it is too large Load Diff

29
service/main.go Normal file
View File

@ -0,0 +1,29 @@
package main
import (
"fmt"
"os"
"git.preston-baxter.com/Preston_PLB/capstone/frontend-service/config"
"git.preston-baxter.com/Preston_PLB/capstone/webhook-service/controllers"
"github.com/gin-gonic/gin"
)
func main() {
config.Init()
r := gin.Default()
controllers.BuildRouter(r)
var addr string
if port := os.Getenv("PORT"); port != "" {
addr = fmt.Sprintf("0.0.0.0:%s", port)
} else {
addr = "0.0.0.0:8080"
}
err := r.Run(addr)
if err != nil {
panic(err)
}
}

63
service/vendors/pco/pco.go vendored Normal file
View File

@ -0,0 +1,63 @@
package pco
import (
"context"
"net/http"
"net/url"
"golang.org/x/oauth2"
)
const PCO_API_URL = "https://api.planningcenteronline.com"
type PcoApiClient struct {
oauth *oauth2.Config
tokenSource oauth2.TokenSource
client *http.Client
url *url.URL
}
func NewClient() *PcoApiClient {
pco_url, err := url.Parse(PCO_API_URL)
if err != nil {
panic(err)
}
pco := &PcoApiClient{
oauth: &oauth2.Config{},
url: pco_url,
}
return pco
}
func NewClientWithOauthConfig(conf *oauth2.Config, tokenSource oauth2.TokenSource) *PcoApiClient {
pco_url, err := url.Parse(PCO_API_URL)
if err != nil {
panic(err)
}
pco := &PcoApiClient{
oauth: conf,
tokenSource: tokenSource,
url: pco_url,
}
return pco
}
func (api *PcoApiClient) getClient() *http.Client {
if api.client == nil {
api.client = oauth2.NewClient(context.Background(), api.tokenSource)
}
return api.client
}
func (api *PcoApiClient) Url() *url.URL {
return api.url
}
func (api *PcoApiClient) Do(req *http.Request) (*http.Response, error) {
return api.getClient().Do(req)
}

60
service/vendors/pco/services.go vendored Normal file
View File

@ -0,0 +1,60 @@
package pco
import (
"fmt"
"net/http"
"git.preston-baxter.com/Preston_PLB/capstone/webhook-service/vendors/pco/services"
"github.com/google/jsonapi"
)
func (api *PcoApiClient) GetPlan(service_type_id, plan_id string) (*services.Plan, error) {
api.Url().Path = fmt.Sprintf("/services/v2/service_types/%s/plans/%s", service_type_id, plan_id)
req, err := http.NewRequest(http.MethodGet, api.Url().String(), nil)
if err != nil {
return nil, err
}
resp, err := api.Do(req)
if err != nil {
return nil, err
}
if resp.StatusCode > 299 || resp.StatusCode < 200 {
return nil, fmt.Errorf("Failed to retrieve plan with status code: %d", resp.StatusCode)
}
plan := &services.Plan{}
err = jsonapi.UnmarshalPayload(resp.Body, plan)
if err != nil {
return nil, err
}
return plan, nil
}
func (api *PcoApiClient) GetPlanTimes(service_type_id, plan_id string) ([]services.PlanTime, error) {
api.Url().Path = fmt.Sprintf("/services/v2/service_types/%s/plans/%s/plan_times", service_type_id, plan_id)
req, err := http.NewRequest(http.MethodGet, api.Url().String(), nil)
if err != nil {
return nil, err
}
resp, err := api.Do(req)
if err != nil {
return nil, err
}
if resp.StatusCode > 299 || resp.StatusCode < 200 {
return nil, fmt.Errorf("Failed to retrieve plan with status code: %d", resp.StatusCode)
}
planTimes, err := jsonapi.UnmarshalManyPayload[services.PlanTime](resp.Body)
if err != nil {
return nil, err
}
return planTimes, nil
}

View File

@ -0,0 +1,5 @@
package services
type AttachmentType struct {
Id string `jsonapi:"primary,AttachmentType"`
}

View File

@ -0,0 +1,5 @@
package services
type LinkedPublishingEpisode struct {
Id string `jsonapi:"primary,LinkedPublishingEpisode"`
}

View File

@ -0,0 +1,5 @@
package services
type Organization struct {
Id string `jsonapi:"primary,Organization"`
}

View File

@ -0,0 +1,5 @@
package services
type Person struct {
Id string `jsonapi:"primary,Person"`
}

42
service/vendors/pco/services/plan.go vendored Normal file
View File

@ -0,0 +1,42 @@
package services
import "time"
type Plan struct {
Id string `jsonapi:"primary,Plan"`
//attrs
CanViewOrder bool `jsonapi:"attr,can_view_order,omitempty"`
CreatedAt time.Time `jsonapi:"attr,created_at,rfc3339,omitempty"`
Dates string `jsonapi:"attr,dates,omitempty"`
FilesExpireAt time.Time `jsonapi:"attr,files_expire_at,rfc3339,omitempty"`
ItemsCount int `jsonapi:"attr,items_count,omitempty"`
LastTimeAt time.Time `jsonapi:"attr,last_time_at,rfc3339,omitempty"`
MultiDay bool `jsonapi:"attr,multi_day,omitempty"`
NeededPositiionsCount int `jsonapi:"attr,needed_positions_count,omitempty"`
OtherTimeCount int `jsonapi:"attr,other_time_count,omitempty"`
Permissions string `jsonapi:"attr,permissions,omitempty"`
PlanNotesCount int `jsonapi:"attr,plan_notes_count,omitempty"`
PlanPeopleCount int `jsonapi:"attr,plan_people_count,omitempty"`
PlanningCenterUrl string `jsonapi:"attr,planning_center_url,omitempty"`
PerfersOrderView bool `jsonapi:"attr,prefers_order_view,omitempty"`
Public bool `jsonapi:"attr,public,omitempty"`
Rehearsable bool `jsonapi:"attr,rehearsable,omitempty"`
RehearsableTimeCount int `jsonapi:"attr,rehearsable_time_count,omitempty"`
RemindersDisabled bool `jsonapi:"attr,reminders_disabled,omitempty"`
SeriesTitle string `jsonapi:"attr,series_title,omitempty"`
ServiceTimeCount int `jsonapi:"attr,service_time_count,omitempty"`
ShortDates string `jsonapi:"attr,short_dates,omitempty"`
SortDate time.Time `jsonapi:"attr,sort_date,rfc3339,omitempty"`
Title string `jsonapi:"attr,title,omitempty"`
TotalLength int `jsonapi:"attr,total_length,omitempty"`
UpdatedAt time.Time `jsonapi:"attr,updated_at,rfc3339,omitempty"`
//relations
ServiceType *ServiceType `jsonapi:"relation,service_type,omitempty"`
NextPlan *Plan `jsonapi:"relation,next_plan,omitempty"`
PreviousPlan *Plan `jsonapi:"relation,previous_plan,omitempty"`
AttachmentTypes *[]AttachmentType `jsonapi:"relation,AttachmentTypes,omitempty"`
Series *Series `jsonapi:"relation,series,omitempty"`
CreatedBy *Person `jsonapi:"relation,created_by,omitempty"`
UpdatedBy *Person `jsonapi:"relation,updated_by,omitempty"`
LinkedPublishingEpisode *LinkedPublishingEpisode `jsonapi:"relation,linked_publishing_episode,omitempty"`
}

View File

@ -0,0 +1,19 @@
package services
import "time"
type PlanTime struct {
//id
Id string `jsonapi:"primary,PlanTime"`
//attributes
CreatedAt time.Time `jsonapi:"attr,created_at,rfc3339,omitempty"`
StartsAt time.Time `jsonapi:"attr,starts_at,rfc3339,omitempty"`
EndsAt time.Time `jsonapi:"attr,ends_at,rfc3339,omitempty"`
LiveEndsAt time.Time `jsonapi:"attr,live_ends_at,rfc3339,omitempty"`
LiveStartsAt time.Time `jsonapi:"attr,live_starts_at,rfc3339,omitempty"`
TeamReminders []interface{} `jsonapi:"attr,team_reminders,rfc3339,omitempty"`
TimeType string `jsonapi:"attr,time_type,omitempty"`
UpdatedAt time.Time `jsonapi:"attr,updated_at,rfc3339,omitempty"`
//relations
AssignedTeams []Team `jsonapi:"relation,assigned_teams,omitempty"`
}

View File

@ -0,0 +1,5 @@
package services
type Series struct {
Id string `jsonapi:"primary,Series"`
}

View File

@ -0,0 +1,5 @@
package services
type ServiceType struct {
Id string `jsonapi:"primary,ServiceType"`
}

View File

@ -0,0 +1,88 @@
package services_test
import (
"bytes"
"strings"
"testing"
"time"
"git.preston-baxter.com/Preston_PLB/capstone/webhook-service/vendors/pco/services"
"github.com/go-playground/assert/v2"
"github.com/google/jsonapi"
)
const valid_string = `{"data":{"type":"Plan","id":"69052110","attributes":{"can_view_order":true,"created_at":"2023-11-11T16:29:47Z","dates":"No dates","items_count":0,"multi_day":false,"needed_positions_count":0,"other_time_count":0,"permissions":"Administrator","plan_notes_count":0,"plan_people_count":0,"planning_center_url":"https://services.planningcenteronline.com/plans/69052110","prefers_order_view":true,"public":false,"rehearsable":true,"rehearsal_time_count":0,"reminders_disabled":false,"service_time_count":0,"short_dates":"No dates","sort_date":"2023-11-11T16:29:47Z","total_length":0,"updated_at":"2023-11-11T16:29:47Z"}}}`
func TestStructs(t *testing.T) {
created_at, err := time.Parse(time.RFC3339, "2023-11-11T16:29:47Z")
if err != nil {
t.Fatal(err)
return
}
sort_date, err := time.Parse(time.RFC3339, "2023-11-11T16:29:47Z")
if err != nil {
t.Fatal(err)
return
}
updated_at, err := time.Parse(time.RFC3339, "2023-11-11T16:29:47Z")
if err != nil {
t.Fatal(err)
return
}
plan := services.Plan{
Id: "69052110",
CanViewOrder: true,
CreatedAt: created_at,
Dates: "No dates",
ItemsCount: 0,
MultiDay: false,
NeededPositiionsCount: 0,
OtherTimeCount: 0,
Permissions: "Administrator",
PlanNotesCount: 0,
PlanPeopleCount: 0,
PlanningCenterUrl: "https://services.planningcenteronline.com/plans/69052110",
PerfersOrderView: true,
Public: false,
Rehearsable: true,
RehearsableTimeCount: 0,
RemindersDisabled: false,
ServiceTimeCount: 0,
ShortDates: "No dates",
Title: "",
TotalLength: 0,
SortDate: sort_date,
UpdatedAt: updated_at,
}
valid_plan := &services.Plan{}
test_plan := &services.Plan{}
err = jsonapi.UnmarshalPayload(strings.NewReader(valid_string), valid_plan)
if err != nil {
t.Fatal(err)
return
}
buf := bytes.NewBuffer([]byte{})
err = jsonapi.MarshalPayload(buf, &plan)
if err != nil {
t.Fatal(err)
return
}
err = jsonapi.UnmarshalPayload(buf, test_plan)
if err != nil {
t.Fatal(err)
return
}
assert.Equal(t, test_plan, valid_plan)
}
func TestMarshalling(t *testing.T) {
}

5
service/vendors/pco/services/team.go vendored Normal file
View File

@ -0,0 +1,5 @@
package services
type Team struct {
Id string `jsonapi:"primary,Team"`
}

115
service/vendors/pco/webhooks.go vendored Normal file
View File

@ -0,0 +1,115 @@
package pco
import (
"bytes"
"fmt"
"io"
"net/http"
"git.preston-baxter.com/Preston_PLB/capstone/webhook-service/vendors/pco/webhooks"
"github.com/google/jsonapi"
)
// gets all current subscriptions
func (api *PcoApiClient) GetSubscriptions() ([]webhooks.Subscription, error) {
api.Url().Path = "/webhooks/v2/subscriptions"
req, err := http.NewRequest(http.MethodGet, api.Url().String(), nil)
if err != nil {
return nil, err
}
resp, err := api.Do(req)
if err != nil {
return nil, err
}
if resp.StatusCode > 299 || resp.StatusCode < 200 {
if raw, err := io.ReadAll(resp.Body); err == nil {
return nil, fmt.Errorf("Failed to retrieve subscriptions with status code: %d. Error %s", resp.StatusCode, string(raw))
} else {
return nil, fmt.Errorf("Failed to retrieve subscriptions with status code: %d", resp.StatusCode)
}
}
subscriptions, err := jsonapi.UnmarshalManyPayload[webhooks.Subscription](resp.Body)
if err != nil {
return nil, err
}
return subscriptions, nil
}
// Posts subscriptions to PCO api and returns a new list of subscriptions
func (api *PcoApiClient) CreateSubscriptions(subscriptions []webhooks.Subscription) ([]webhooks.Subscription, error) {
api.Url().Path = "/webhooks/v2/subscriptions"
body := bytes.NewBuffer([]byte{})
err := jsonapi.MarshalPayload(body, subscriptions)
if err != nil {
return nil, err
}
req, err := http.NewRequest(http.MethodPost, api.Url().String(), body)
if err != nil {
return nil, err
}
req.Header.Add("Content-Type", "application/json")
resp, err := api.Do(req)
if err != nil {
return nil, err
}
if resp.StatusCode > 299 || resp.StatusCode < 200 {
if raw, err := io.ReadAll(resp.Body); err == nil {
return nil, fmt.Errorf("Failed to create subscriptions with status code: %d. Error %s", resp.StatusCode, string(raw))
} else {
return nil, fmt.Errorf("Failed to create subscriptions with status code: %d", resp.StatusCode)
}
}
new_subscriptions, err := jsonapi.UnmarshalManyPayload[webhooks.Subscription](resp.Body)
if err != nil {
return nil, err
}
return new_subscriptions, nil
}
// Posts subcription to PCO api and updates the subscription at the pointer that was passed to the fuinction with the server response
func (api *PcoApiClient) CreateSubscription(subscription *webhooks.Subscription) error {
api.Url().Path = "/webhooks/v2/subscriptions"
body := bytes.NewBuffer([]byte{})
err := jsonapi.MarshalPayload(body, subscription)
if err != nil {
return err
}
req, err := http.NewRequest(http.MethodPost, api.Url().String(), body)
if err != nil {
return err
}
req.Header.Add("Content-Type", "application/json")
resp, err := api.Do(req)
if err != nil {
return err
}
if resp.StatusCode > 299 || resp.StatusCode < 200 {
if raw, err := io.ReadAll(resp.Body); err == nil {
return fmt.Errorf("Failed to create subscriptions with status code: %d. Error %s", resp.StatusCode, string(raw))
} else {
return fmt.Errorf("Failed to create subscription with status code: %d", resp.StatusCode)
}
}
err = jsonapi.UnmarshalPayload(resp.Body, subscription)
if err != nil {
return err
}
return nil
}

View File

@ -0,0 +1,28 @@
package webhooks
import (
"strings"
"git.preston-baxter.com/Preston_PLB/capstone/webhook-service/vendors/pco/services"
"github.com/google/jsonapi"
)
// Structure delivered to target when sending webhooks
type EventDelivery struct {
//uuid of the EventDelivery
ID string `jsonapi:"primary,EventDelivery"`
//name of the event being sent. ex: services.v2.events.plan.updated
//this coressponds to the scopes you set when configuring webhooks
Name string `jsonapi:"attr,name"`
//number of attemts taken to deliver the event
Attempt int `jsonapi:"attr,attempt"`
//JSON:API string of the event
Payload string `jsonapi:"attr,payload"`
//Owner Organization of the event
Organization *services.Organization `jsonapi:"relation,organization"`
}
// Unmarshall payload of EventDelivery into the struct you think it is
func (event *EventDelivery) UnmarshallPayload(obj any) error {
return jsonapi.UnmarshalPayload(strings.NewReader(event.Payload), obj)
}

View File

@ -0,0 +1,27 @@
package webhooks
import (
"strings"
"testing"
"git.preston-baxter.com/Preston_PLB/capstone/webhook-service/vendors/pco/services"
"github.com/go-playground/assert/v2"
"github.com/google/jsonapi"
)
func TestUnmarshallPayload(t *testing.T) {
raw := `{"data":[{"id":"87f49852-1a2a-45cb-b4d2-0fa30eda0823","type":"EventDelivery","attributes":{"name":"services.v2.events.plan.created","attempt":1,"payload":"{\"data\":{\"type\":\"Plan\",\"id\":\"69259663\",\"attributes\":{\"can_view_order\":true,\"created_at\":\"2023-11-23T13:34:09Z\",\"dates\":\"No dates\",\"files_expire_at\":null,\"items_count\":0,\"last_time_at\":null,\"multi_day\":false,\"needed_positions_count\":0,\"other_time_count\":0,\"permissions\":\"Administrator\",\"plan_notes_count\":0,\"plan_people_count\":0,\"planning_center_url\":\"https://services.planningcenteronline.com/plans/69259663\",\"prefers_order_view\":false,\"public\":false,\"rehearsable\":true,\"rehearsal_time_count\":0,\"reminders_disabled\":false,\"series_title\":null,\"service_time_count\":0,\"short_dates\":\"No dates\",\"sort_date\":\"2023-11-23T13:34:09Z\",\"title\":null,\"total_length\":0,\"updated_at\":\"2023-11-23T13:34:09Z\"},\"relationships\":{\"service_type\":{\"data\":{\"type\":\"ServiceType\",\"id\":\"1429991\"}},\"next_plan\":{\"data\":null},\"previous_plan\":{\"data\":null},\"attachment_types\":{\"data\":[]},\"series\":{\"data\":null},\"created_by\":{\"data\":{\"type\":\"Person\",\"id\":\"136901110\"}},\"updated_by\":{\"data\":{\"type\":\"Person\",\"id\":\"136901110\"}},\"linked_publishing_episode\":{\"data\":null}},\"links\":{\"all_attachments\":\"https://api.planningcenteronline.com/services/v2/service_types/1429991/plans/69259663/all_attachments\",\"attachments\":\"https://api.planningcenteronline.com/services/v2/service_types/1429991/plans/69259663/attachments\",\"attendances\":\"https://api.planningcenteronline.com/services/v2/service_types/1429991/plans/69259663/attendances\",\"contributors\":\"https://api.planningcenteronline.com/services/v2/service_types/1429991/plans/69259663/contributors\",\"import_template\":\"https://api.planningcenteronline.com/services/v2/service_types/1429991/plans/69259663/import_template\",\"item_reorder\":\"https://api.planningcenteronline.com/services/v2/service_types/1429991/plans/69259663/item_reorder\",\"items\":\"https://api.planningcenteronline.com/services/v2/service_types/1429991/plans/69259663/items\",\"live\":\"https://api.planningcenteronline.com/services/v2/service_types/1429991/plans/69259663/live\",\"my_schedules\":\"https://api.planningcenteronline.com/services/v2/service_types/1429991/plans/69259663/my_schedules\",\"needed_positions\":\"https://api.planningcenteronline.com/services/v2/service_types/1429991/plans/69259663/needed_positions\",\"next_plan\":\"https://api.planningcenteronline.com/services/v2/service_types/1429991/plans/69259663/next_plan\",\"notes\":\"https://api.planningcenteronline.com/services/v2/service_types/1429991/plans/69259663/notes\",\"plan_times\":\"https://api.planningcenteronline.com/services/v2/service_types/1429991/plans/69259663/plan_times\",\"previous_plan\":\"https://api.planningcenteronline.com/services/v2/service_types/1429991/plans/69259663/previous_plan\",\"series\":null,\"signup_teams\":\"https://api.planningcenteronline.com/services/v2/service_types/1429991/plans/69259663/signup_teams\",\"team_members\":\"https://api.planningcenteronline.com/services/v2/service_types/1429991/plans/69259663/team_members\",\"self\":\"https://api.planningcenteronline.com/services/v2/service_types/1429991/plans/69259663\",\"html\":\"https://services.planningcenteronline.com/plans/69259663\"}},\"included\":[],\"meta\":{\"can_include\":[\"contributors\",\"my_schedules\",\"plan_times\",\"series\"],\"parent\":{\"id\":\"1429991\",\"type\":\"ServiceType\"},\"event_time\":\"2023-11-23T13:34:09Z\"}}"},"relationships":{"organization":{"data":{"type":"Organization","id":"456240"}}}}]}`
deliveries, err := jsonapi.UnmarshalManyPayload[EventDelivery](strings.NewReader(raw))
if err != nil {
t.Fatal(err)
}
plan := &services.Plan{}
err = deliveries[0].UnmarshallPayload(plan)
if err != nil {
t.Fatal(err)
}
assert.Equal(t, plan.Id, "69259663")
}

View File

@ -0,0 +1,27 @@
package webhooks
import "time"
type Subscription struct {
Id string `jsonapi:"primary,Subscription" bson:"id"`
//attrs
Active bool `jsonapi:"attr,active,omitempty" bson:"active"`
ApplicationId int `jsonapi:"attr,application_id,omitempty" bson:"application_id"`
AuthenticitySecret string `jsonapi:"attr,authenticity_secret,omitempty" bson:"authenticity_secret"`
CreatedAt time.Time `jsonapi:"attr,created_at,rfc3339,omitempty" bson:"created_at"`
UpdatedAt time.Time `jsonapi:"attr,updated_at,rfc3339,omitempty" bson:"updated_at"`
Name string `jsonapi:"attr,name,omitempty" bson:"name"`
Url string `jsonapi:"attr,url,omitempty" bson:"url"`
}
type WebhookSubscription struct {
Id string `jsonapi:"primary,WebhookSubscription" bson:"id"`
//attrs
Active bool `jsonapi:"attr,active,omitempty" bson:"active"`
ApplicationId string `jsonapi:"attr,application_id,omitempty" bson:"application_id"`
AuthenticitySecret string `jsonapi:"attr,authenticity_secret,omitempty" bson:"authenticity_secret"`
CreatedAt time.Time `jsonapi:"attr,created_at,rfc3339,omitempty" bson:"created_at"`
UpdatedAt time.Time `jsonapi:"attr,updated_at,rfc3339,omitempty" bson:"updated_at"`
Name string `jsonapi:"attr,name,omitempty" bson:"name"`
Url string `jsonapi:"attr,url,omitempty" bson:"url"`
}

78
service/vendors/pco/webhooks_test.go vendored Normal file
View File

@ -0,0 +1,78 @@
package pco
import (
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"time"
"git.preston-baxter.com/Preston_PLB/capstone/frontend-service/config"
"git.preston-baxter.com/Preston_PLB/capstone/frontend-service/db/models"
"git.preston-baxter.com/Preston_PLB/capstone/webhook-service/vendors/pco/webhooks"
"golang.org/x/oauth2"
)
var pcoMockAccount models.VendorAccount = models.VendorAccount{
OauthCredentials: &models.OauthCredential{
AccessToken: "asdf;alskdfgha;dklrha;ldkfga;sldkf",
ExpiresIn: 1234786012983,
ExpiresAt: time.Now().Add(24 * time.Hour),
TokenType: "bearer",
RefreshToken: "asdfas;lkdjfas;dlkfj;asdlkj;aslf",
},
Name: "pco",
}
func TestCreateSubscriptions(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
raw, err := io.ReadAll(r.Body)
if err != nil {
panic(err)
}
fmt.Printf("Resp: %s", string(raw))
w.Write([]byte(`{"data":[{"type":"Subscription","attributes":{"active":true,"name":"eventsandstuff","url":"https://thing.com/asdf/asdf/asdf"}},{"type":"Subscription","attributes":{"active":true,"name":"eventsandstuff","url":"https://thing.com/asdf/asdf/asdf"}}]}`))
}))
defer ts.Close()
tokenSource := oauth2.StaticTokenSource(pcoMockAccount.Token())
mockPco := config.VendorConfig{
ClientId: "as;dlkfja;slkdfj;aslkdfj;asdkl",
ClientSecret: "as;dlfkjas;ldkfja;slkdfj;alsdkfjas;dklj",
Scopes: []string{},
AuthUri: ts.URL,
TokenUri: ts.URL,
RefreshEncode: "json",
WebhookSecret: "as;dlfja;slkdja;slkdfj;alskdfj;alskdfa;slkdj",
}
pcoApi := NewClientWithOauthConfig(mockPco.OauthConfig(), tokenSource)
if newUrl, err := url.Parse(ts.URL); err == nil {
pcoApi.url = newUrl
} else {
t.Fatalf("%s", err)
}
mockSubscriptoins := []webhooks.Subscription{
{
Active: true,
Name: "eventsandstuff",
Url: "https://thing.com/asdf/asdf/asdf",
},
{
Active: true,
Name: "eventsandstuff",
Url: "https://thing.com/asdf/asdf/asdf",
},
}
_, err := pcoApi.CreateSubscriptions(mockSubscriptoins)
if err != nil {
t.Fatal(err)
}
}

46
service/vendors/youtube/youtube.go vendored Normal file
View File

@ -0,0 +1,46 @@
package youtube
import (
"time"
"google.golang.org/api/youtube/v3"
)
const (
STATUS_PRIVATE = "private"
STATUS_PUBLIC = "public"
ISO_8601 = "2006-01-02T15:04:05.000Z"
)
// Inserts Broadcast into youtube
func InsertBroadcast(service *youtube.Service, title string, startTime time.Time, privacyStatus string) (*youtube.LiveBroadcast, error) {
liveBroadcast := &youtube.LiveBroadcast{
Snippet: &youtube.LiveBroadcastSnippet{
Title: title,
ScheduledStartTime: startTime.Format(ISO_8601),
},
Status: &youtube.LiveBroadcastStatus{
PrivacyStatus: privacyStatus,
},
}
return service.LiveBroadcasts.Insert([]string{"snippet", "status"}, liveBroadcast).Do()
}
// given a broadcast ID update the broadcast
func UpdateBroadcast(service *youtube.Service, id, title string, startTime time.Time, privacyStatus string) (*youtube.LiveBroadcast, error) {
liveBroadcast := &youtube.LiveBroadcast{
Id: id,
Snippet: &youtube.LiveBroadcastSnippet{
Title: title,
ScheduledStartTime: startTime.Format(ISO_8601),
},
Status: &youtube.LiveBroadcastStatus{
PrivacyStatus: privacyStatus,
},
}
return service.LiveBroadcasts.Update([]string{"snippet", "status"}, liveBroadcast).Do()
}
func DeleteBroadcast(service *youtube.Service, id string) error {
return service.LiveBroadcasts.Delete(id).Do()
}

BIN
service/webhook-service Executable file

Binary file not shown.

46
ui/.air.toml Normal file
View File

@ -0,0 +1,46 @@
root = "."
testdata_dir = "testdata"
tmp_dir = "tmp"
[build]
args_bin = []
bin = "./tmp/main"
cmd = "make local-build"
delay = 1000
exclude_dir = ["assets", "tmp", "vendor", "testdata", "dist", "docker", "node_modules"]
exclude_file = []
exclude_regex = ["_test.go", "_templ.go"]
exclude_unchanged = false
follow_symlink = false
full_bin = ""
include_dir = []
include_ext = ["go", "tpl", "tmpl", "html", "templ", "css"]
include_file = []
kill_delay = "0s"
log = "build-errors.log"
poll = false
poll_interval = 0
post_cmd = []
pre_cmd = []
rerun = false
rerun_delay = 500
send_interrupt = false
stop_on_error = false
[color]
app = ""
build = "yellow"
main = "magenta"
runner = "green"
watcher = "cyan"
[log]
main_only = false
time = false
[misc]
clean_on_exit = false
[screen]
clear_on_rebuild = false
keep_scroll = true

9
ui/.gitignore vendored Normal file
View File

@ -0,0 +1,9 @@
dist/*
tmp/*
**/*_templ.go
templates/*.html
docker/tmp/*
node_modules/*
node_modules
config.yaml
conf.yaml

23
ui/Makefile Normal file
View File

@ -0,0 +1,23 @@
BASE_URL="us-central1-docker.pkg.dev/pbaxter-infra/capstone-repo"
local-build:
rm -f templates/*.go; templ generate -path ./templates
npx tailwindcss -i tailwind/index.css -o dist/output.css
GOEXPERIMENT=loopvar go build -o ./tmp/main .
local-run: local-build
./tmp/main
infra-clean:
cd docker; docker compose down
infra:
cd docker; docker compose up -d --remove-orphans
build:
docker build -f ../docker/ui.dockerfile . -t frontend-service:latest
docker tag frontend-service:latest $(BASE_URL)/frontend-service:latest
deploy: build
docker push $(BASE_URL)/frontend-service:latest

85
ui/config/config.go Normal file
View File

@ -0,0 +1,85 @@
package config
import (
"strings"
"github.com/spf13/viper"
"golang.org/x/oauth2"
)
type config struct {
Mongo *MongoConfig `mapstructure:"mongo"`
Vendors map[string]*VendorConfig `mapstructure:"vendors"`
JwtSecret string `mapstructure:"jwt_secret"`
Env string `mapstructure:"env"`
AppSettings *AppSettings `mapstructure:"app_settings"`
}
type AppSettings struct {
WebhookServiceUrl string `mapstructure:"webhook_service_url"`
FrontendServiceUrl string `mapstructure:"frontend_service_url"`
}
type MongoConfig struct {
Uri string `mapstructure:"uri"`
EntDb string `mapstructure:"ent_db"`
EntCol string `mapstructure:"ent_col"`
LockDb string `mapstructure:"lock_db"`
LockCol string `mapstructure:"lock_col"`
}
type VendorConfig struct {
ClientId string `mapstructure:"client_id"`
ClientSecret string `mapstructure:"client_secret"`
Scopes []string `mapstructure:"scopes"`
AuthUri string `mapstructure:"auth_uri"`
TokenUri string `mapstructure:"token_uri"`
RefreshEncode string `mapstructure:"refresh_encode"`
WebhookSecret string `mapstructure:"webhook_secret"`
scope string
}
func (vendor *VendorConfig) Scope() string {
if vendor.scope == "" {
vendor.scope = strings.Join(vendor.Scopes, " ")
}
return vendor.scope
}
func (vendor *VendorConfig) OauthConfig() *oauth2.Config {
return &oauth2.Config{
ClientID: vendor.ClientId,
ClientSecret: vendor.ClientSecret,
Endpoint: oauth2.Endpoint{
AuthURL: vendor.AuthUri,
TokenURL: vendor.TokenUri,
AuthStyle: oauth2.AuthStyleInParams,
},
RedirectURL: "",
Scopes: vendor.Scopes,
}
}
var cfg *config
func Init() {
viper.SetConfigName("config") // name of config file (without extension)
viper.SetConfigType("yaml") // REQUIRED if the config file does not have the extension in the name
viper.AddConfigPath("/etc/capstone") // path to look for the config file in
err := viper.ReadInConfig()
if err != nil {
panic(err)
}
cfg = &config{}
err = viper.Unmarshal(cfg)
if err != nil {
panic(err)
}
}
func Config() *config {
return cfg
}

161
ui/controllers/actions.go Normal file
View File

@ -0,0 +1,161 @@
package controllers
import (
"errors"
"fmt"
"strings"
"git.preston-baxter.com/Preston_PLB/capstone/frontend-service/config"
"git.preston-baxter.com/Preston_PLB/capstone/frontend-service/db/models"
"git.preston-baxter.com/Preston_PLB/capstone/webhook-service/vendors/pco"
"git.preston-baxter.com/Preston_PLB/capstone/webhook-service/vendors/pco/webhooks"
"github.com/gin-gonic/gin"
"golang.org/x/oauth2"
)
type actionFunc func(user *models.User) error
var (
actionFuncs map[string]actionFunc = map[string]actionFunc{"pco.plan": setupPcoSubscriptions}
webhooksTemplate map[string]webhooks.Subscription = map[string]webhooks.Subscription{
"services.v2.events.plan.created": {
Active: true,
Name: "services.v2.events.plan.created",
Url: "https://%s/pco/%s",
},
"services.v2.events.plan.updated": {
Active: true,
Name: "services.v2.events.plan.updated",
Url: "https://%s/pco/%s",
},
"services.v2.events.plan.deleted": {
Active: true,
Name: "services.v2.events.plan.destroyed",
Url: "https://%s/pco/%s",
},
}
)
func AddActionFromForm(c *gin.Context) {
user := getUserFromContext(c)
if user == nil {
log.Warnf("Could not find user in context. Trying to redner Action form")
badRequest(c, "No user available in context")
return
}
//parse the form
c.Request.ParseForm()
var source []string
var action []string
//validate source
if str := c.Request.FormValue("source"); str != "" {
source = strings.Split(str, ".")
} else {
log.Warnf("Form request was partially or fully blank")
badRequest(c, "Form request was partially or fully blank")
return
}
//validate action
if str := c.Request.FormValue("action"); str != "" {
action = strings.Split(str, ".")
} else {
log.Warnf("Form request was partially or fully blank")
badRequest(c, "Form request was partially or fully blank")
return
}
//setup action listener
if afunc, ok := actionFuncs[strings.Join(source, ".")]; ok {
err := afunc(user)
if err != nil {
log.WithError(err).Error("Failed to setup actions")
serverError(c, "Failed to setup actions")
return
}
}
//Build mappings
am := &models.ActionMapping{
UserId: user.Id,
SourceEvent: &models.Event{
VendorName: source[0],
Key: source[1],
Fields: map[string]string{},
},
Action: &models.Action{
VendorName: action[0],
Type: action[1],
Fields: map[string]string{},
},
}
err := mongo.SaveModel(am)
if err != nil {
log.WithError(err).Error("Failed to setup actions")
serverError(c, "Failed to setup actions")
return
}
c.Redirect(302, "/dashboard")
}
func setupPcoSubscriptions(user *models.User) error {
// Get PCO vendor account
conf := config.Config()
pcoAccount, err := mongo.FindVendorAccountByUser(user.Id, models.PCO_VENDOR_NAME)
if err != nil {
return err
}
//build pco api
tokenSource := oauth2.ReuseTokenSource(pcoAccount.Token(), mongo.NewVendorTokenSource(pcoAccount))
pcoApi := pco.NewClientWithOauthConfig(conf.Vendors[models.PCO_VENDOR_NAME].OauthConfig(), tokenSource)
//Check if subscriptions already exist
webhookMap := make(map[string]webhooks.Subscription)
subscriptions, err := pcoApi.GetSubscriptions()
if err != nil {
return errors.Join(fmt.Errorf("Failed to find subscriptions for user: %s", user.Id), err)
}
//Loop through found subscriptions
for _, sub := range subscriptions {
//if subsciption is in the templates look to add it to our map
if templ, ok := webhooksTemplate[sub.Name]; ok {
//if the subscription is for our url add it to our map
url := fmt.Sprintf(templ.Url, conf.AppSettings.WebhookServiceUrl, user.Id.Hex())
if url == sub.Url {
webhookMap[sub.Name] = sub
}
}
}
builtHooks := make([]webhooks.Subscription, 0, len(webhooksTemplate))
//Build subscriptions
for _, templ := range webhooksTemplate {
if _, ok := webhookMap[templ.Name]; !ok {
builtHooks = append(builtHooks, webhooks.Subscription{
Active: true,
Name: templ.Name,
Url: fmt.Sprintf(templ.Url, conf.AppSettings.WebhookServiceUrl, user.Id.Hex()),
})
}
}
//Todo: save subscriptions for succesfull hooksetups
for index := range builtHooks {
err = pcoApi.CreateSubscription(&builtHooks[index])
if err != nil {
return errors.Join(fmt.Errorf("Failed to create subscription: %s for user: %s", builtHooks[index].Name, user.Id), err)
}
}
//Save Subscriptions
err = mongo.SaveSubscriptionsForUser(user.Id, builtHooks...)
if err != nil {
return errors.Join(fmt.Errorf("Failed to save subscriptions for user: %s", user.Id), err)
}
return nil
}

216
ui/controllers/auth.go Normal file
View File

@ -0,0 +1,216 @@
package controllers
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"regexp"
"time"
"git.preston-baxter.com/Preston_PLB/capstone/frontend-service/config"
"git.preston-baxter.com/Preston_PLB/capstone/frontend-service/db/models"
"git.preston-baxter.com/Preston_PLB/capstone/frontend-service/templates"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
"golang.org/x/crypto/bcrypt"
)
const AUTH_COOKIE_NAME = "authorization"
var VALIDATE_EMAIL_REGEX = regexp.MustCompile(`^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$`)
type LoginPostBody struct {
Email string `json:"email"`
Password string `json:"password"`
}
func SignUpHandler(c *gin.Context) {
//get uname and password.
conf := config.Config()
reqBody := &LoginPostBody{}
c.Request.ParseForm()
reqBody.Email = c.Request.FormValue("email")
reqBody.Password = c.Request.FormValue("password")
if reqBody.Email == "" {
log.Warn("Request contained no email")
renderTempl(c, templates.SignupPage("Please provide an email"))
return
}
if reqBody.Password == "" {
log.Warn("Request contained no password")
renderTempl(c, templates.SignupPage("Please provide a password"))
return
}
//Verify username and password
if ok := VALIDATE_EMAIL_REGEX.Match([]byte(reqBody.Email)); !ok {
log.Warnf("User provided email field is not valid: %s", reqBody.Email)
renderTempl(c, templates.SignupPage("You eamil is invalid. Please try again"))
return
}
user, err := mongo.FindUserByEmail(reqBody.Email)
if err != nil {
log.WithError(err).Errorf("Failed to lookup user: %s", reqBody.Email)
renderTempl(c, templates.SignupPage("Error occured. Please try again later"))
return
}
if user != nil {
log.Warnf("User: %s, already exists", reqBody.Email)
renderTempl(c, templates.SignupPage(fmt.Sprintf("user already exists for %s", reqBody.Email)))
return
}
//create new user
user = &models.User{}
passHash, err := bcrypt.GenerateFromPassword([]byte(reqBody.Password), 10)
if err != nil {
log.WithError(err).Errorf("Passowrd hash failed for user: %s", reqBody.Email)
renderTempl(c, templates.SignupPage("Signup failed. Please try again later"))
return
}
user.PassowrdHash = string(passHash)
user.Email = reqBody.Email
err = mongo.SaveModel(user)
if err != nil {
log.WithError(err).Errorf("Failed to write user to DB for user: %s", reqBody.Email)
renderTempl(c, templates.SignupPage("Signup failed. Please try again later"))
return
}
now := time.Now().Unix()
exp := time.Now().Add(12 * time.Hour).Unix()
//build jwt
token := jwt.NewWithClaims(jwt.SigningMethodHS256,
AuthClaims{
Subject: user.MongoId().Hex(),
Expires: exp,
IssuedAt: now,
NotBefore: now,
Issuer: "capstone.preston-baxter.com",
Audience: "capstone.preston-baxter.com",
},
)
jwtStr, err := token.SignedString([]byte(conf.JwtSecret))
if err != nil {
log.WithError(err).Errorf("Failed to encode jwt for user: %s", reqBody.Email)
renderTempl(c, templates.SignupPage("Signup failed. Please try again later"))
return
}
//store jwt as cookie
//TODO: Make sure set secure for prd deployment
c.SetCookie(AUTH_COOKIE_NAME, jwtStr, 3600*24, "", "", false, true)
c.Redirect(302, "/dashboard")
}
func LoginHandler(c *gin.Context) {
//get uname and password.
conf := config.Config()
reqBody := &LoginPostBody{}
c.Request.ParseForm()
reqBody.Email = c.Request.FormValue("email")
reqBody.Password = c.Request.FormValue("password")
if reqBody.Email == "" {
log.Warn("Request contained no email")
renderTempl(c, templates.LoginPage("Please provide an email"))
return
}
if reqBody.Password == "" {
log.Warn("Request contained no password")
renderTempl(c, templates.LoginPage("Please provide a password"))
return
}
//Verify username and password
if ok := VALIDATE_EMAIL_REGEX.Match([]byte(reqBody.Email)); !ok {
log.Warnf("User provided email field is not valid: %s", reqBody.Email)
renderTempl(c, templates.SignupPage("You eamil is invalid. Please try again"))
return
}
user, err := mongo.FindUserByEmail(reqBody.Email)
if err != nil {
log.WithError(err).Errorf("Failed to lookup user: %s", reqBody.Email)
renderTempl(c, templates.LoginPage(err.Error()))
return
}
if user == nil {
log.Warnf("No user was found for: %s", reqBody.Email)
renderTempl(c, templates.LoginPage(fmt.Sprintf("No user found for %s", reqBody.Email)))
return
}
if err := bcrypt.CompareHashAndPassword([]byte(user.PassowrdHash), []byte(reqBody.Password)); err != nil {
log.Warnf("Password does not match for user: %s", reqBody.Email)
renderTempl(c, templates.LoginPage("Email or password are incorrect"))
return
}
now := time.Now().Unix()
exp := time.Now().Add(12 * time.Hour).Unix()
//build jwt
token := jwt.NewWithClaims(jwt.SigningMethodHS256,
AuthClaims{
Subject: user.MongoId().Hex(),
Expires: exp,
IssuedAt: now,
NotBefore: now,
Issuer: "capstone.preston-baxter.com",
Audience: "capstone.preston-baxter.com",
},
)
jwtStr, err := token.SignedString([]byte(conf.JwtSecret))
if err != nil {
renderTempl(c, templates.LoginPage("An error occured. Please try again later"))
}
//store jwt as cookie
var secure bool
if conf.Env == "dev" {
secure = false
} else {
secure = true
}
c.SetCookie(AUTH_COOKIE_NAME, jwtStr, 3600*24, "", "", secure, true)
c.Redirect(302, "/dashboard")
}
func LogoutHandler(c *gin.Context) {
conf := config.Config()
var secure bool
if conf.Env == "dev" {
secure = false
} else {
secure = true
}
c.SetCookie("authorization", "", 3600*24, "", "", secure, true)
c.Redirect(302, "/login")
}
func getAuthHash(c *gin.Context) string {
jwtToken, err := c.Cookie(AUTH_COOKIE_NAME)
if err != nil {
return ""
}
h := sha256.New()
h.Write([]byte(jwtToken))
return hex.EncodeToString(h.Sum(nil))
}

View File

@ -0,0 +1,128 @@
package controllers
import (
"net/http"
"time"
"git.preston-baxter.com/Preston_PLB/capstone/frontend-service/config"
"git.preston-baxter.com/Preston_PLB/capstone/frontend-service/db/models"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
)
const USER_OBJ_KEY = "userObj"
type AuthClaims struct {
Subject string `json:"sub"`
Expires int64 `json:"exp"`
IssuedAt int64 `json:"iat"`
NotBefore int64 `json:"nbf"`
Issuer string `json:"iss"`
Audience string `json:"aud"`
}
func (claims AuthClaims) GetExpirationTime() (*jwt.NumericDate, error) {
time := time.Unix(claims.Expires, 0)
return jwt.NewNumericDate(time), nil
}
func (claims AuthClaims) GetIssuedAt() (*jwt.NumericDate, error) {
time := time.Unix(claims.IssuedAt, 0)
return jwt.NewNumericDate(time), nil
}
func (claims AuthClaims) GetNotBefore() (*jwt.NumericDate, error) {
time := time.Unix(claims.NotBefore, 0)
return jwt.NewNumericDate(time), nil
}
func (claims AuthClaims) GetIssuer() (string, error) {
return claims.Issuer, nil
}
func (claims AuthClaims) GetSubject() (string, error) {
return claims.Subject, nil
}
func (claims AuthClaims) GetAudience() (jwt.ClaimStrings, error) {
return []string{claims.Subject}, nil
}
func AuthMiddleware(strict bool) gin.HandlerFunc {
conf := config.Config()
return func(c *gin.Context) {
//check for cookie
token, err := c.Cookie("authorization")
if err != nil {
if err == http.ErrNoCookie {
if strict {
c.Redirect(301, "/login")
return
} else {
return
}
} else {
log.WithError(err).Error("Unable to get cookie from browser")
c.AbortWithError(504, err)
return
}
}
claims := &AuthClaims{}
parsedToken, err := jwt.ParseWithClaims(token, claims, func(token *jwt.Token) (any, error) {
return []byte(conf.JwtSecret), nil
})
if err != nil {
if err == jwt.ErrTokenExpired {
log.Warn("Redirecting, jwt expired")
c.Redirect(301, "/login")
return
} else {
if strict {
log.Warnf("Redirecting, jwt issue: %s", err)
c.Redirect(301, "/login")
return
} else {
log.Warnf("Jwt is invalid, but auth is not strict. Reason: %s", err)
return
}
}
}
if !parsedToken.Valid {
if strict {
log.Warn("Redirecting, jwt invalid")
c.Redirect(301, "/login")
return
} else {
log.Warn("Jwt is invalid, but auth is not strict")
return
}
}
user, err := mongo.FindUserById(claims.Subject)
if err != nil {
log.WithError(err).Errorf("Unable to get user: %s from DB", claims.Subject)
c.AbortWithError(502, err)
}
if user == nil {
log.Errorf("Unable to find user: %s in DB", claims.Subject)
c.AbortWithError(502, nil)
}
//store user object reference in session.
c.Set(USER_OBJ_KEY, user)
}
}
func getUserFromContext(c *gin.Context) *models.User {
if raw, exists := c.Get(USER_OBJ_KEY); exists {
if user, ok := raw.(*models.User); ok {
return user
}
}
return nil
}

View File

@ -0,0 +1,127 @@
package controllers
import (
"fmt"
"git.preston-baxter.com/Preston_PLB/capstone/frontend-service/templates"
"github.com/gin-gonic/gin"
"golang.org/x/text/language"
"golang.org/x/text/message"
)
func GetAddActionForm(c *gin.Context) {
user := getUserFromContext(c)
if user == nil {
log.Warnf("Could not find user in context. Trying to redner Action form")
badRequest(c, "No user available in context")
return
}
accounts, err := mongo.FindAllVendorAccountsByUser(user.Id)
if err != nil {
log.WithError(err).Errorf("Failed to find vendor accounts for: %s", user.Email)
serverError(c, "No user available in context")
return
}
renderTempl(c, templates.DashboardActionModal(accounts))
}
type DashboardMetric struct {
Title string
PrimaryValue string
SecondaryValue string
Subtitle string
}
type dashboardMetricFunc func(c *gin.Context) *DashboardMetric
var metricFuncMap = map[string]dashboardMetricFunc{"default": defaultMetricFunction, "events_received": eventsRecievedMetricFunction, "streams_scheduled": streamsScheduledMetricFunction}
func GetMetricCard(c *gin.Context) {
user := getUserFromContext(c)
if user == nil {
log.Warnf("Could not find user in context. Trying to redner Action form")
badRequest(c, "No user available in context")
return
}
if metric, ok := c.GetQuery("metric"); ok {
if metricFunc, mok := metricFuncMap[metric]; mok {
renderDashboardMetric(c, &metricFunc)
return
}
}
//send default metric function
log.Warn("Failed to find metricfunc")
defaultFunc := metricFuncMap["default"]
renderDashboardMetric(c, &defaultFunc)
}
func defaultMetricFunction(c *gin.Context) *DashboardMetric {
return &DashboardMetric{
Title: "Err",
PrimaryValue: "0.00",
SecondaryValue: "0.00",
Subtitle: "something went wrong",
}
}
func renderDashboardMetric(c *gin.Context, metricFunc *dashboardMetricFunc) {
metric := (*metricFunc)(c)
renderTempl(c, templates.DashboardCard(metric.Title, metric.PrimaryValue, metric.SecondaryValue, metric.Subtitle))
}
func eventsRecievedMetricFunction(c *gin.Context) *DashboardMetric {
user := getUserFromContext(c)
events, err := mongo.AggregateVendorEventReport(user.Id)
if err != nil {
log.WithError(err).Errorf("Failed to find events for user: %s", user.Id.Hex())
return defaultMetricFunction(c)
}
totalEvents := 0
biggestVendor := 0
for index, event := range events {
totalEvents += event.Count
if events[biggestVendor].Count < event.Count {
biggestVendor = index
}
}
p := message.NewPrinter(language.English)
metric := &DashboardMetric{
Title: "Events Recieved",
PrimaryValue: p.Sprintf("%d", totalEvents),
}
if len(events) > 0 {
metric.Subtitle = fmt.Sprintf("Most events from: %s", events[biggestVendor].Name)
}
return metric
}
func streamsScheduledMetricFunction(c *gin.Context) *DashboardMetric {
user := getUserFromContext(c)
events, err := mongo.AggregateBroadcastReport(user.Id)
if err != nil {
log.WithError(err).Errorf("Failed to find broadcast report for user: %s", user.Id.Hex())
return defaultMetricFunction(c)
}
totalEvents := 0
for _, event := range events {
totalEvents += event.Count
}
p := message.NewPrinter(language.English)
return &DashboardMetric{
Title: "Broadcasts scheduled",
PrimaryValue: p.Sprintf("%d", totalEvents),
SecondaryValue: "",
Subtitle: "Scheduled to youtube",
}
}

View File

@ -0,0 +1,71 @@
package controllers
import (
"git.preston-baxter.com/Preston_PLB/capstone/frontend-service/config"
"git.preston-baxter.com/Preston_PLB/capstone/frontend-service/db"
"github.com/gin-contrib/cors"
"github.com/gin-gonic/gin"
"github.com/sirupsen/logrus"
)
var mongo *db.DB
var log *logrus.Logger
func BuildRouter(r *gin.Engine) {
conf := config.Config()
var err error
mongo, err = db.NewClient(conf.Mongo.Uri)
if err != nil {
panic(err)
}
log = logrus.New()
log.SetFormatter(&logrus.TextFormatter{
DisableColors: true,
})
log.SetLevel(logrus.DebugLevel)
r.Use(cors.Default())
r.Static("/static", "/var/capstone/dist")
//mainpage
r.GET("/", AuthMiddleware(false), LandingPage)
//Auth
r.GET("/login", AuthMiddleware(false), LoginPage)
r.GET("/signup", AuthMiddleware(false), SignUpPage)
r.POST("/login", LoginHandler)
r.POST("/signup", SignUpHandler)
r.POST("/logout", LogoutHandler)
dashboard := r.Group("/dashboard")
dashboard.Use(AuthMiddleware(true))
dashboard.GET("", DashboardPage)
//Dashboard Actions
dashboardActions := dashboard.Group("/action")
dashboardActions.POST("/add", AddActionFromForm)
//Dashboard Forms
dashboardForms := dashboard.Group("/forms")
dashboardForms.GET("/addAction", GetAddActionForm)
//Dashboard Components
dashboardComponents := dashboard.Group("/components")
dashboardComponents.GET("/metric_card", GetMetricCard)
//Dashboard Events
dashboardEvents := dashboard.Group("/events")
dashboardEvents.GET("", EventsPage)
dashboardEventComponents := dashboardEvents.Group("/components")
dashboardEventComponents.GET("/table_data", GetTableComponent)
//Vendor stuff
vendor := r.Group("/vendor")
vendor.Use(AuthMiddleware(true))
youtube := vendor.Group("/youtube")
youtube.POST("/initiate", InitiateYoutubeOuath)
youtube.GET("/callback", ReceiveYoutubeOauth)
pco := vendor.Group("/pco")
pco.POST("/initiate", InitiatePCOOuath)
pco.GET("/callback", RecievePCOOuath)
}

View File

@ -0,0 +1,120 @@
package controllers
import (
"strings"
"time"
"git.preston-baxter.com/Preston_PLB/capstone/frontend-service/templates"
"github.com/gin-gonic/gin"
)
var (
eventsTableMap = map[string]eventsTableFunc{"default": defaultTableData, "events_for_user": eventsForUserTableData, "actions_for_user": actionsForUserTableData}
)
type eventsTableFunc func(c *gin.Context) templates.TableData
func GetTableComponent(c *gin.Context) {
user := getUserFromContext(c)
if user == nil {
log.Warnf("Could not find user in context. Trying to redner Action form")
badRequest(c, "No user available in context")
return
}
if table, ok := c.GetQuery("table_name"); ok {
if tableFunc, mok := eventsTableMap[table]; mok {
renderEventTable(c, table, &tableFunc)
return
}
}
//send default metric function
log.Warn("Failed to find eventsTableFunc")
defaultFunc := eventsTableMap["default"]
renderEventTable(c, "default", &defaultFunc)
}
func defaultTableData(c *gin.Context) templates.TableData {
return [][]string{{"id", "col 1", "col 2"}, {"row data", "item data", "stuff"}}
}
func renderEventTable(c *gin.Context, title string, tableFunc *eventsTableFunc) {
tableData := (*tableFunc)(c)
renderTempl(c, templates.EventTableData(tableData, title))
}
func eventsForUserTableData(c *gin.Context) templates.TableData {
//User can't be nil because we check before we get here
user := getUserFromContext(c)
events, err := mongo.FindEventsRecievedByUserId(user.Id)
if err != nil {
log.WithError(err).Errorf("Failed to find events for user: %s to load table data", user.Id.Hex())
return defaultTableData(c)
}
//check for filter
filter, filter_exists := c.GetQuery("filter")
table := make([][]string, len(events)+1)
index := 1
for _, event := range events {
arr := []string{event.CreatedAt.Format(time.Stamp), strings.ToUpper(event.VendorName), event.CorrelationId, event.Type}
if filter_exists {
//if the filter exists loop through the row. Check if anything meets the filter
pass := false
for _, item := range arr {
//if we already have a match short circuit. If we don't we can potentially flip from false -> true
pass = pass || strings.Contains(item, filter)
}
//If we did not find a matching item continue
if !pass {
continue
}
}
//We either had no filter or passed the filter check. Add to the pool
table[index] = arr
index += 1
}
table[0] = []string{"Timestamp", "Vendor", "Id", "Event Type"}
return table[0:index]
}
func actionsForUserTableData(c *gin.Context) templates.TableData {
//User can't be nil because we check before we get here
user := getUserFromContext(c)
actions, err := mongo.FindActionTakenByUserId(user.Id)
if err != nil {
log.WithError(err).Errorf("Failed to find actions for user: %s to load table data", user.Id.Hex())
return defaultTableData(c)
}
//check for filter
filter, filter_exists := c.GetQuery("filter")
index := 1
table := make([][]string, len(actions)+1)
for _, action := range actions {
arr := []string{action.CreatedAt.Format(time.Stamp), action.VendorName, action.CorrelationId, action.Result}
if filter_exists {
//if the filter exists loop through the row. Check if anything meets the filter
pass := false
for _, item := range arr {
//if we already have a match short circuit. If we don't we can potentially flip from false -> true
pass = pass || strings.Contains(item, filter)
}
//If we did not find a matching item continue
if !pass {
continue
}
}
table[index] = arr
index += 1
}
table[0] = []string{"Timestamp", "Vendor", "Id", "Result"}
return table[0:index]
}

84
ui/controllers/pages.go Normal file
View File

@ -0,0 +1,84 @@
package controllers
import (
"errors"
"sync"
"git.preston-baxter.com/Preston_PLB/capstone/frontend-service/db/models"
"git.preston-baxter.com/Preston_PLB/capstone/frontend-service/templates"
"github.com/gin-gonic/gin"
)
func LandingPage(c *gin.Context) {
if raw, exists := c.Get(USER_OBJ_KEY); exists {
if user, ok := raw.(*models.User); ok {
renderTempl(c, templates.LandingPage(user))
return
}
}
renderTempl(c, templates.LandingPage(nil))
}
func LoginPage(c *gin.Context) {
renderTempl(c, templates.LoginPage(""))
}
func SignUpPage(c *gin.Context) {
renderTempl(c, templates.SignupPage(""))
}
func DashboardPage(c *gin.Context) {
user := getUserFromContext(c)
if user == nil {
log.Error("No user found in context")
serverError(c, "No user found in context")
return
}
//Split database fetching into go routines
var vendors []models.VendorAccount
var actions []models.ActionMapping
//TODO: find a generic way to do this.
errs := make([]error, 2)
//Use waitgroup to syncronize
waitGroup := new(sync.WaitGroup)
waitGroup.Add(2)
go func(wg *sync.WaitGroup) {
vendors, errs[0] = mongo.FindAllVendorAccountsByUser(user.MongoId())
wg.Done()
}(waitGroup)
go func(wg *sync.WaitGroup) {
actions, errs[1] = mongo.FindActionMappingsByUser(user.MongoId())
wg.Done()
}(waitGroup)
//after this line we are in sync
waitGroup.Wait()
//handle errors
for _, err := range errs {
if err != nil {
log.WithError(errors.Join(errs...)).Error("Failed to do database lookup when retrieving dashbDashboardPage")
serverError(c, "Failed to do database lookup when retrieving dashbDashboardPage")
return
}
}
renderTempl(c, templates.DashboardPage(user, vendors, actions))
}
func EventsPage(c *gin.Context) {
user := getUserFromContext(c)
if user == nil {
log.Error("No user found in context")
serverError(c, "No user found in context")
return
}
renderTempl(c, templates.EventsPage(user))
}

129
ui/controllers/pco.go Normal file
View File

@ -0,0 +1,129 @@
package controllers
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"git.preston-baxter.com/Preston_PLB/capstone/frontend-service/config"
"git.preston-baxter.com/Preston_PLB/capstone/frontend-service/db/models"
"github.com/gin-gonic/gin"
)
const PCO_REDIRECT_URI = "https://%s/vendor/pco/callback"
func InitiatePCOOuath(c *gin.Context) {
conf := config.Config()
vendorConfig := conf.Vendors[models.PCO_VENDOR_NAME]
init_url, err := url.Parse(vendorConfig.AuthUri)
if err != nil {
//we should not get here
panic(err)
}
q := init_url.Query()
q.Add("client_id", vendorConfig.ClientId)
q.Add("redirect_uri", fmt.Sprintf(PCO_REDIRECT_URI, conf.AppSettings.FrontendServiceUrl))
q.Add("response_type", "code")
q.Add("scope", vendorConfig.Scope())
init_url.RawQuery = q.Encode()
c.Redirect(302, init_url.String())
}
func RecievePCOOuath(c *gin.Context) {
conf := config.Config()
vendorConfig := conf.Vendors[models.PCO_VENDOR_NAME]
user := getUserFromContext(c)
if user == nil {
log.Error("Unable to find user in context")
c.AbortWithStatus(502)
}
code := c.Query("code")
//validate returned code
if code == "" {
log.Error("Youtube OAuth response did not contain a code. Possible CSRF")
c.AbortWithStatus(502)
return
}
client := http.Client{}
token_url, err := url.Parse(vendorConfig.TokenUri)
if err != nil {
//we should not get here
panic(err)
}
//Make request to google for credentials
q := token_url.Query()
q.Add("code", code)
q.Add("client_id", vendorConfig.ClientId)
q.Add("client_secret", vendorConfig.ClientSecret)
q.Add("redirect_uri", fmt.Sprintf(PCO_REDIRECT_URI, conf.AppSettings.FrontendServiceUrl))
q.Add("grant_type", "authorization_code")
req, err := http.NewRequest("POST", token_url.String(), strings.NewReader(q.Encode()))
if err != nil {
log.WithError(err).Errorf("Failed to generate request with the following url: '%s'", token_url.String())
c.AbortWithStatus(502)
return
}
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
resp, err := client.Do(req)
if err != nil {
log.WithError(err).Errorf("Failed to make request to the following url: '%s'", token_url.String())
c.AbortWithStatus(502)
return
}
rawBody, err := io.ReadAll(resp.Body)
if err != nil {
log.WithError(err).Errorf("Failed to read body from the following url: '%s'", token_url.String())
c.AbortWithStatus(502)
return
}
if resp.StatusCode != 200 {
log.Errorf("Response failed with status code: %d. Error: %s", resp.StatusCode, string(rawBody))
c.AbortWithStatus(502)
return
}
oauthResp := &models.OauthCredential{}
err = json.Unmarshal(rawBody, oauthResp)
if err != nil {
log.WithError(err).Errorf("Failed to Unmarshal response from the following url: '%s'", token_url.String())
c.AbortWithStatus(502)
}
log.Infof("oauthResp: %v", *oauthResp)
//Set expires at time but shave some time off to refresh token before expire date
oauthResp.ExpiresAt = time.Now().Add(time.Duration(oauthResp.ExpiresIn)*time.Second - 10)
//store credentials
vendor := &models.VendorAccount{
UserId: user.Id,
OauthCredentials: oauthResp,
Name: models.PCO_VENDOR_NAME,
}
err = mongo.SaveModel(vendor)
if err != nil {
log.WithError(err).Errorf("Failed to save credentials for user: %s", user.Email)
c.AbortWithStatus(502)
return
}
c.Redirect(302, "/dashboard")
}

32
ui/controllers/util.go Normal file
View File

@ -0,0 +1,32 @@
package controllers
import (
"bytes"
"github.com/a-h/templ"
"github.com/gin-gonic/gin"
)
// Responds with 200ok and the rendered template
func renderTempl(c *gin.Context, tmpl templ.Component) {
buf := bytes.NewBuffer([]byte{})
tmpl.Render(c.Request.Context(), buf)
c.Data(200, "text/html", buf.Bytes())
}
func badRequest(c *gin.Context, reason string) {
c.JSON(400, map[string]string{"error": reason})
}
func serverError(c *gin.Context, reason string) {
c.JSON(504, map[string]string{"error": reason})
}
func notFound(c *gin.Context, reason string) {
c.JSON(404, map[string]string{"error": reason})
}
func unauthorized(c *gin.Context, reason string) {
c.JSON(403, map[string]string{"error": reason})
}

139
ui/controllers/youtube.go Normal file
View File

@ -0,0 +1,139 @@
package controllers
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"git.preston-baxter.com/Preston_PLB/capstone/frontend-service/config"
"git.preston-baxter.com/Preston_PLB/capstone/frontend-service/db/models"
"github.com/gin-gonic/gin"
)
const YOUTUBE_REDIRECT_URI = "https://%s/vendor/youtube/callback"
func InitiateYoutubeOuath(c *gin.Context) {
conf := config.Config()
vendorConfig := conf.Vendors[models.YOUTUBE_VENDOR_NAME]
init_url, err := url.Parse(vendorConfig.AuthUri)
if err != nil {
//we should not get here
panic(err)
}
q := init_url.Query()
//https://developers.google.com/youtube/v3/guides/auth/server-side-web-apps#httprest_1
q.Add("client_id", vendorConfig.ClientId)
q.Add("redirect_uri", fmt.Sprintf(YOUTUBE_REDIRECT_URI, conf.AppSettings.FrontendServiceUrl))
q.Add("response_type", "code")
q.Add("scope", vendorConfig.Scope())
q.Add("access_type", "offline")
//used to prevent CSRF
q.Add("state", getAuthHash(c))
init_url.RawQuery = q.Encode()
c.Redirect(302, init_url.String())
}
func ReceiveYoutubeOauth(c *gin.Context) {
conf := config.Config()
vendorConfig := conf.Vendors[models.YOUTUBE_VENDOR_NAME]
user := getUserFromContext(c)
if user == nil {
log.Error("Unable to find user in context")
c.AbortWithStatus(502)
}
code := c.Query("code")
respHash := c.Query("state")
//validate returned code
if code == "" {
log.Error("Youtube OAuth response did not contain a code. Possible CSRF")
c.AbortWithStatus(502)
return
}
//validate state
if respHash == "" || respHash != getAuthHash(c) {
log.Error("Youtube OAuth response did not contain the correct hash. Possible CSRF")
c.AbortWithStatus(502)
return
}
client := http.Client{}
token_url, err := url.Parse(vendorConfig.TokenUri)
if err != nil {
//we should not get here
panic(err)
}
//Make request to google for credentials
q := token_url.Query()
q.Add("code", code)
q.Add("client_id", vendorConfig.ClientId)
q.Add("client_secret", vendorConfig.ClientSecret)
q.Add("redirect_uri", fmt.Sprintf(YOUTUBE_REDIRECT_URI, conf.AppSettings.FrontendServiceUrl))
q.Add("grant_type", "authorization_code")
req, err := http.NewRequest("POST", token_url.String(), strings.NewReader(q.Encode()))
if err != nil {
log.WithError(err).Errorf("Failed to generate request with the following url: '%s'", token_url.String())
c.AbortWithStatus(502)
return
}
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
resp, err := client.Do(req)
if err != nil {
log.WithError(err).Errorf("Failed to make request to the following url: '%s'", token_url.String())
c.AbortWithStatus(502)
return
}
rawBody, err := io.ReadAll(resp.Body)
if err != nil {
log.WithError(err).Errorf("Failed to read body from the following url: '%s'", token_url.String())
c.AbortWithStatus(502)
return
}
if resp.StatusCode != 200 {
log.Errorf("Response failed with status code: %d. Error: %s", resp.StatusCode, string(rawBody))
c.AbortWithStatus(502)
return
}
oauthResp := &models.OauthCredential{}
err = json.Unmarshal(rawBody, oauthResp)
if err != nil {
log.WithError(err).Errorf("Failed to Unmarshal response from the following url: '%s'", token_url.String())
c.AbortWithStatus(502)
}
//Set expires at time but shave some time off to refresh token before expire date
oauthResp.ExpiresAt = time.Now().Add(time.Duration(oauthResp.ExpiresIn)*time.Second - 10)
//store credentials
vendor := &models.VendorAccount{
UserId: user.Id,
OauthCredentials: oauthResp,
Name: models.YOUTUBE_VENDOR_NAME,
}
err = mongo.SaveModel(vendor)
if err != nil {
log.WithError(err).Errorf("Failed to save credentials for user: %s", user.Email)
c.AbortWithStatus(502)
return
}
c.Redirect(302, "/dashboard")
}

33
ui/db/actions.go Normal file
View File

@ -0,0 +1,33 @@
package db
import (
"context"
"git.preston-baxter.com/Preston_PLB/capstone/frontend-service/config"
"git.preston-baxter.com/Preston_PLB/capstone/frontend-service/db/models"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.mongodb.org/mongo-driver/mongo"
"go.mongodb.org/mongo-driver/mongo/options"
)
func (db *DB) FindActionMappingsByUser(userId primitive.ObjectID) ([]models.ActionMapping, error) {
conf := config.Config()
opts := options.Find()
res, err := db.client.Database(conf.Mongo.EntDb).Collection(conf.Mongo.EntCol).Find(context.Background(), bson.M{"user_id": userId, "obj_info.ent": models.ACTION_MAPPING_TYPE}, opts)
if err != nil {
if err == mongo.ErrNoDocuments {
return nil, nil
}
return nil, err
}
actions := []models.ActionMapping{}
err = res.All(context.Background(), &actions)
if err != nil {
return nil, err
}
return actions, nil
}

190
ui/db/audit.go Normal file
View File

@ -0,0 +1,190 @@
package db
import (
"context"
"errors"
"sync"
"git.preston-baxter.com/Preston_PLB/capstone/frontend-service/config"
"git.preston-baxter.com/Preston_PLB/capstone/frontend-service/db/models"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.mongodb.org/mongo-driver/mongo"
"go.mongodb.org/mongo-driver/mongo/options"
)
// return audit trail for user
func (db *DB) FindAuditTrailForUser(userId primitive.ObjectID) ([]models.EventRecieved, []models.ActionTaken, error) {
conf := config.Config()
//Build sync things
wg := new(sync.WaitGroup)
wg.Add(2)
errs := make([]error, 2)
events := []models.EventRecieved{}
actions := []models.ActionTaken{}
//Spawn event recieved goroutine
go func(wg *sync.WaitGroup) {
defer wg.Done()
opts := options.Find()
res, err := db.client.Database(conf.Mongo.EntDb).Collection(conf.Mongo.EntCol).Find(context.Background(), bson.M{"user_id": userId, "obj_info.ent": models.EVENT_RECIEVED_TYPE}, opts)
if err != nil {
if err == mongo.ErrNoDocuments {
return
}
errs[0] = err
return
}
err = res.All(context.Background(), &events)
if err != nil {
errs[0] = err
return
}
}(wg)
//Spawn action taken goroutine
go func(wg *sync.WaitGroup) {
defer wg.Done()
opts := options.Find()
res, err := db.client.Database(conf.Mongo.EntDb).Collection(conf.Mongo.EntCol).Find(context.Background(), bson.M{"user_id": userId, "obj_info.ent": models.ACTION_MAPPING_TYPE}, opts)
if err != nil {
if err == mongo.ErrNoDocuments {
return
}
errs[1] = err
return
}
err = res.All(context.Background(), &actions)
if err != nil {
errs[1] = err
return
}
}(wg)
//wait for go routines to finish
wg.Wait()
//if there was an error return the combined error
if err := errors.Join(errs...); err != nil {
return nil, nil, err
}
return events, actions, nil
}
func (db *DB) FindEventsRecievedByUserId(userId primitive.ObjectID) ([]models.EventRecieved, error) {
conf := config.Config()
opts := options.Find()
res, err := db.client.Database(conf.Mongo.EntDb).Collection(conf.Mongo.EntCol).Find(context.Background(), bson.M{"user_id": userId, "obj_info.ent": models.EVENT_RECIEVED_TYPE}, opts)
if err != nil {
if err == mongo.ErrNoDocuments {
return nil, nil
}
return nil, err
}
events := []models.EventRecieved{}
err = res.All(context.Background(), &events)
if err != nil {
return nil, err
}
return events, nil
}
func (db *DB) FindActionTakenByUserId(userId primitive.ObjectID) ([]models.ActionTaken, error) {
conf := config.Config()
opts := options.Find()
res, err := db.client.Database(conf.Mongo.EntDb).Collection(conf.Mongo.EntCol).Find(context.Background(), bson.M{"user_id": userId, "obj_info.ent": models.ACTION_TAKEN_TYPE}, opts)
if err != nil {
if err == mongo.ErrNoDocuments {
return nil, nil
}
return nil, err
}
events := []models.ActionTaken{}
err = res.All(context.Background(), &events)
if err != nil {
return nil, err
}
return events, nil
}
type VendorEventReport struct {
Count int `bson:"count"`
Name string `bson:"_id"`
}
func (db *DB) AggregateBroadcastReport(userId primitive.ObjectID) ([]VendorEventReport, error) {
conf := config.Config()
opts := options.Aggregate().SetAllowDiskUse(false)
aggregation := bson.A{
bson.D{{Key: "$match", Value: bson.D{{Key: "obj_info.ent", Value: models.ACTION_TAKEN_TYPE}, {Key: "result", Value: "Created Broadcast"}}}},
bson.D{
{Key: "$group",
Value: bson.D{
{Key: "_id", Value: nil},
{Key: "count", Value: bson.D{{Key: "$sum", Value: 1}}},
},
},
},
}
res, err := db.client.Database(conf.Mongo.EntDb).Collection(conf.Mongo.EntCol).Aggregate(context.Background(), aggregation, opts)
if err != nil {
if err == mongo.ErrNoDocuments {
return nil, nil
}
return nil, err
}
events := []VendorEventReport{}
err = res.All(context.Background(), &events)
if err != nil {
return nil, err
}
return events, nil
}
func (db *DB) AggregateVendorEventReport(userId primitive.ObjectID) ([]VendorEventReport, error) {
conf := config.Config()
opts := options.Aggregate().SetAllowDiskUse(false)
aggregation := bson.A{
bson.D{{Key: "$match", Value: bson.D{{Key: "obj_info.ent", Value: models.EVENT_RECIEVED_TYPE}}}},
bson.D{
{Key: "$group",
Value: bson.D{
{Key: "_id", Value: "$vendor_name"},
{Key: "count", Value: bson.D{{Key: "$sum", Value: 1}}},
},
},
},
}
res, err := db.client.Database(conf.Mongo.EntDb).Collection(conf.Mongo.EntCol).Aggregate(context.Background(), aggregation, opts)
if err != nil {
if err == mongo.ErrNoDocuments {
return nil, nil
}
return nil, err
}
events := []VendorEventReport{}
err = res.All(context.Background(), &events)
if err != nil {
return nil, err
}
return events, nil
}

183
ui/db/db.go Normal file
View File

@ -0,0 +1,183 @@
package db
import (
"context"
"errors"
"time"
"git.preston-baxter.com/Preston_PLB/capstone/frontend-service/config"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.mongodb.org/mongo-driver/mongo"
"go.mongodb.org/mongo-driver/mongo/options"
"go.mongodb.org/mongo-driver/mongo/readpref"
)
// Interface for any object that wants to take advantage of the DB package
type Model interface {
//Should return the _id field of the object if it exits
//if it is new it should generate a new objectId
MongoId() primitive.ObjectID
//It is expected that this will update the CommonFields part of the model
UpdateObjectInfo()
}
type DB struct {
client *mongo.Client
}
func NewClient(uri string) (*DB, error) {
opts := options.Client().ApplyURI(uri).SetConnectTimeout(60 * time.Second)
client, err := mongo.Connect(context.Background(), opts)
if err != nil {
return nil, err
}
if err := client.Ping(context.Background(), readpref.Primary()); err != nil {
return nil, err
}
return &DB{client: client}, nil
}
// Upserts
func (db *DB) SaveModel(m Model) error {
conf := config.Config()
m.UpdateObjectInfo()
opts := options.Update().SetUpsert(true)
res, err := db.client.Database(conf.Mongo.EntDb).Collection(conf.Mongo.EntCol).UpdateOne(context.Background(), bson.M{"_id": m.MongoId()}, bson.M{"$set": m}, opts)
if err != nil {
return err
}
if res.MatchedCount == 0 && res.ModifiedCount == 0 && res.UpsertedCount == 0 {
return errors.New("Failed to save model properly")
}
return nil
}
func (db *DB) SaveModels(m ...Model) error {
conf := config.Config()
writeEntry := make([]mongo.WriteModel, len(m))
for index, model := range m {
entry := mongo.NewUpdateOneModel()
entry.SetFilter(bson.M{"_id": model.MongoId()})
entry.SetUpsert(true)
entry.SetUpdate(bson.M{"$set": model})
model.UpdateObjectInfo()
writeEntry[index] = entry
}
opts := options.BulkWrite()
res, err := db.client.Database(conf.Mongo.EntDb).Collection(conf.Mongo.EntCol).BulkWrite(context.Background(), writeEntry, opts)
if err != nil {
return err
}
if res.MatchedCount == 0 && res.ModifiedCount == 0 && res.UpsertedCount == 0 {
return errors.New("Failed to save models properly")
}
return nil
}
func SaveModelSlice[T Model](db *DB, m ...T) error {
return saveModels[T](db, m...)
}
// For allowing more varidaic like things
func saveModels[T Model](db *DB, m ...T) error {
conf := config.Config()
writeEntry := make([]mongo.WriteModel, len(m))
for index, model := range m {
entry := mongo.NewUpdateOneModel()
entry.SetFilter(bson.M{"_id": model.MongoId()})
entry.SetUpsert(true)
entry.SetUpdate(bson.M{"$set": model})
model.UpdateObjectInfo()
writeEntry[index] = entry
}
opts := options.BulkWrite()
res, err := db.client.Database(conf.Mongo.EntDb).Collection(conf.Mongo.EntCol).BulkWrite(context.Background(), writeEntry, opts)
if err != nil {
return err
}
if res.MatchedCount == 0 && res.ModifiedCount == 0 && res.UpsertedCount == 0 {
return errors.New("Failed to save models properly")
}
return nil
}
func DeleteModelSlice[T Model](db *DB, m ...T) error {
return deleteModels[T](db, m...)
}
// For allowing more varidaic like things
func deleteModels[T Model](db *DB, m ...T) error {
conf := config.Config()
writeEntry := make([]mongo.WriteModel, len(m))
for index, model := range m {
entry := mongo.NewDeleteOneModel()
entry.SetFilter(bson.M{"_id": model.MongoId()})
model.UpdateObjectInfo()
writeEntry[index] = entry
}
opts := options.BulkWrite()
res, err := db.client.Database(conf.Mongo.EntDb).Collection(conf.Mongo.EntCol).BulkWrite(context.Background(), writeEntry, opts)
if err != nil {
return err
}
if res.DeletedCount == 0 {
return errors.New("Failed to delete models properly")
}
return nil
}
// Doesn't upsert
func (db *DB) InsertModel(m Model) error {
conf := config.Config()
m.UpdateObjectInfo()
opts := options.InsertOne()
_, err := db.client.Database(conf.Mongo.EntDb).Collection(conf.Mongo.EntCol).InsertOne(context.Background(), m, opts)
if err != nil {
return err
}
return nil
}
func (db *DB) DeleteModel(m Model) error {
conf := config.Config()
opts := options.Delete()
res, err := db.client.Database(conf.Mongo.EntDb).Collection(conf.Mongo.EntCol).DeleteOne(context.Background(), bson.M{"_id": m.MongoId()}, opts)
if err != nil {
return err
}
if res.DeletedCount == 0 {
return errors.New("There was no item to delete")
}
return nil
}

51
ui/db/models/actions.go Normal file
View File

@ -0,0 +1,51 @@
package models
import (
"time"
"go.mongodb.org/mongo-driver/bson/primitive"
)
const (
ACTION_MAPPING_TYPE = "action"
)
type ActionMapping struct {
*CommonFields `bson:"obj_info"`
Id primitive.ObjectID `bson:"_id,omitempty"`
UserId primitive.ObjectID `bson:"user_id,omitempty"`
SourceEvent *Event `bson:"source_event,omitempty"`
Action *Action `bson:"action,omitempty"`
}
type Action struct {
VendorName string `bson:"vendor_name,omitempty"`
Type string `bson:"type,omitempty"`
Fields map[string]string `bson:"fields,omitempty"`
}
type Event struct {
VendorName string `bson:"vendor_name,omitempty"`
Key string `bson:"key,omitempty"`
Fields map[string]string `bson:"fields,omitempty"`
}
func (am *ActionMapping) MongoId() primitive.ObjectID {
if am.Id.IsZero() {
now := time.Now()
am.Id = primitive.NewObjectIDFromTimestamp(now)
}
return am.Id
}
func (am *ActionMapping) UpdateObjectInfo() {
now := time.Now()
if am.CommonFields == nil {
am.CommonFields = new(CommonFields)
am.EntityType = ACTION_MAPPING_TYPE
am.CreatedAt = now
}
am.UpdatedAt = now
}

72
ui/db/models/audit.go Normal file
View File

@ -0,0 +1,72 @@
package models
import (
"time"
"go.mongodb.org/mongo-driver/bson/primitive"
)
const (
EVENT_RECIEVED_TYPE = "audit_event_recieved"
ACTION_TAKEN_TYPE = "audit_action_taken"
)
// Event Recieved
type EventRecieved struct {
*CommonFields `bson:"obj_info"`
Id primitive.ObjectID `bson:"_id,omitempty"`
UserId primitive.ObjectID `bson:"user_id,omitempty"` //what user is this associated too
VendorName string `bson:"vendor_name,omitempty"` //Vendor name of who sent us the event
VendorId string `bson:"vendor_id,omitempty"`
CorrelationId string `bson:"correlation_id,omitempty"` //list of entities effected or created from action
Type string `bson:"type,omitempty"` //type of event
}
func (obj *EventRecieved) MongoId() primitive.ObjectID {
if obj.Id.IsZero() {
now := time.Now()
obj.Id = primitive.NewObjectIDFromTimestamp(now)
}
return obj.Id
}
func (obj *EventRecieved) UpdateObjectInfo() {
now := time.Now()
if obj.CommonFields == nil {
obj.CommonFields = new(CommonFields)
obj.EntityType = EVENT_RECIEVED_TYPE
obj.CreatedAt = now
}
obj.UpdatedAt = now
}
// Action Taken
type ActionTaken struct {
*CommonFields `bson:"obj_info"`
Id primitive.ObjectID `bson:"_id,omitempty"`
UserId primitive.ObjectID `bson:"user_id,omitempty"` //what user is this associated too
TriggeringEvent primitive.ObjectID `bson:"triggering_event,omitempty"` //what triggered this action to be taken
Result string `bson:"result,omitempty"` //list of entities effected or created from action
CorrelationId string `bson:"correlation_id,omitempty"` //list of entities effected or created from action
VendorName string `bson:"vendor_name,omitempty"` //Vendor name that the action was taken against
}
func (obj *ActionTaken) MongoId() primitive.ObjectID {
if obj.Id.IsZero() {
now := time.Now()
obj.Id = primitive.NewObjectIDFromTimestamp(now)
}
return obj.Id
}
func (obj *ActionTaken) UpdateObjectInfo() {
now := time.Now()
if obj.CommonFields == nil {
obj.CommonFields = new(CommonFields)
obj.EntityType = ACTION_TAKEN_TYPE
obj.CreatedAt = now
}
obj.UpdatedAt = now
}

9
ui/db/models/models.go Normal file
View File

@ -0,0 +1,9 @@
package models
import "time"
type CommonFields struct {
EntityType string `bson:"ent,omitempty"`
CreatedAt time.Time `bson:"created_at,omitempty"`
UpdatedAt time.Time `bson:"updated_at,omitempty"`
}

46
ui/db/models/oauth.go Normal file
View File

@ -0,0 +1,46 @@
package models
import (
"time"
"go.mongodb.org/mongo-driver/bson/primitive"
)
type OauthCredential struct {
AccessToken string `bson:"access_token,omitempty" json:"access_token,omitempty"`
ExpiresIn int `bson:"expires_in,omitempty" json:"expires_in,omitempty"`
ExpiresAt time.Time `bson:"expires_at,omitempty" json:"expires_at,omitempty"`
TokenType string `bson:"token_type,omitempty" json:"token_type,omitempty"`
Scope string `bson:"scope,omitempty" json:"scope,omitempty"`
RefreshToken string `bson:"refresh_token,omitempty" json:"refresh_token,omitempty"`
}
const TOKEN_LOCK_TYPE = "token_lock"
type TokenLock struct {
*CommonFields `bson:"obj_info"`
Id primitive.ObjectID `bson:"_id"`
VendorId primitive.ObjectID `bson:"vendor_id"`
TokenId string `bson:"token_id"`
Refreshed bool `bson:"refreshed"`
}
func (tl *TokenLock) MongoId() primitive.ObjectID {
if tl.Id.IsZero() {
now := time.Now()
tl.Id = primitive.NewObjectIDFromTimestamp(now)
}
return tl.Id
}
func (tl *TokenLock) UpdateObjectInfo() {
now := time.Now()
if tl.CommonFields == nil {
tl.CommonFields = new(CommonFields)
tl.EntityType = TOKEN_LOCK_TYPE
tl.CreatedAt = now
}
tl.UpdatedAt = now
}

36
ui/db/models/pco.go Normal file
View File

@ -0,0 +1,36 @@
package models
import (
"time"
"git.preston-baxter.com/Preston_PLB/capstone/webhook-service/vendors/pco/webhooks"
"go.mongodb.org/mongo-driver/bson/primitive"
)
const PCO_SUBSCRIPTION_TYPE = "pco_subscription"
type PcoSubscription struct {
*CommonFields `bson:"obj_info"`
Id primitive.ObjectID `bson:"_id,omitempty"`
UserId primitive.ObjectID `bson:"user_id,omitempty"`
Details *webhooks.Subscription `bson:"details,omitempty"`
}
func (obj *PcoSubscription) MongoId() primitive.ObjectID {
if obj.Id.IsZero() {
now := time.Now()
obj.Id = primitive.NewObjectIDFromTimestamp(now)
}
return obj.Id
}
func (obj *PcoSubscription) UpdateObjectInfo() {
now := time.Now()
if obj.CommonFields == nil {
obj.CommonFields = new(CommonFields)
obj.EntityType = PCO_SUBSCRIPTION_TYPE
obj.CreatedAt = now
}
obj.UpdatedAt = now
}

37
ui/db/models/user.go Normal file
View File

@ -0,0 +1,37 @@
package models
import (
"time"
"go.mongodb.org/mongo-driver/bson/primitive"
)
const USER_TYPE string = "user"
type User struct {
*CommonFields `bson:"obj_info"`
Id primitive.ObjectID `bson:"_id"`
Email string `bson:"email,omitempty"`
PassowrdHash string `bson:"password_hash,omitempty"`
}
func (user *User) MongoId() primitive.ObjectID {
if user.Id.IsZero() {
now := time.Now()
user.Id = primitive.NewObjectIDFromTimestamp(now)
}
return user.Id
}
func (user *User) UpdateObjectInfo() {
now := time.Now()
if user.CommonFields == nil {
user.CommonFields = new(CommonFields)
user.EntityType = USER_TYPE
user.CreatedAt = now
}
user.UpdatedAt = now
}

52
ui/db/models/vendor.go Normal file
View File

@ -0,0 +1,52 @@
package models
import (
"time"
"go.mongodb.org/mongo-driver/bson/primitive"
"golang.org/x/oauth2"
)
const VENDOR_ACCOUNT_TYPE = "vendor_account"
const (
YOUTUBE_VENDOR_NAME = "youtube"
PCO_VENDOR_NAME = "pco"
)
type VendorAccount struct {
*CommonFields `bson:"obj_info"`
Id primitive.ObjectID `bson:"_id,omitempty"`
UserId primitive.ObjectID `bson:"user_id,omitempty"`
Secret string `bson:"secret,omitempty"`
OauthCredentials *OauthCredential `bson:"ouath_credentials,omitempty"`
Name string `bson:"name"`
}
func (va *VendorAccount) MongoId() primitive.ObjectID {
if va.Id.IsZero() {
now := time.Now()
va.Id = primitive.NewObjectIDFromTimestamp(now)
}
return va.Id
}
func (va *VendorAccount) UpdateObjectInfo() {
now := time.Now()
if va.CommonFields == nil {
va.CommonFields = new(CommonFields)
va.EntityType = VENDOR_ACCOUNT_TYPE
va.CreatedAt = now
}
va.UpdatedAt = now
}
func (va *VendorAccount) Token() *oauth2.Token {
return &oauth2.Token{
AccessToken: va.OauthCredentials.AccessToken,
TokenType: va.OauthCredentials.TokenType,
RefreshToken: va.OauthCredentials.RefreshToken,
Expiry: va.OauthCredentials.ExpiresAt,
}
}

37
ui/db/models/youtube.go Normal file
View File

@ -0,0 +1,37 @@
package models
import (
"time"
"go.mongodb.org/mongo-driver/bson/primitive"
"google.golang.org/api/youtube/v3"
)
const YOUTUBE_BROADCAST_TYPE = "youtube_broadcast"
type YoutubeBroadcast struct {
*CommonFields `bson:"obj_info"`
Id primitive.ObjectID `bson:"_id,omitempty"`
UserId primitive.ObjectID `bson:"user_id,omitempty"`
CorrelationId string `bson:"correlation_id,omitempty"`
Details *youtube.LiveBroadcast `bson:"details,omitempty"`
}
func (obj *YoutubeBroadcast) MongoId() primitive.ObjectID {
if obj.Id.IsZero() {
now := time.Now()
obj.Id = primitive.NewObjectIDFromTimestamp(now)
}
return obj.Id
}
func (obj *YoutubeBroadcast) UpdateObjectInfo() {
now := time.Now()
if obj.CommonFields == nil {
obj.CommonFields = new(CommonFields)
obj.EntityType = YOUTUBE_BROADCAST_TYPE
obj.CreatedAt = now
}
obj.UpdatedAt = now
}

50
ui/db/pco.go Normal file
View File

@ -0,0 +1,50 @@
package db
import (
"context"
"git.preston-baxter.com/Preston_PLB/capstone/frontend-service/config"
"git.preston-baxter.com/Preston_PLB/capstone/frontend-service/db/models"
"git.preston-baxter.com/Preston_PLB/capstone/webhook-service/vendors/pco/webhooks"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.mongodb.org/mongo-driver/mongo"
"go.mongodb.org/mongo-driver/mongo/options"
)
// using userId and event string return PCO Subscriptions saved to the DB
func (db *DB) FindPcoSubscriptionForUser(userId primitive.ObjectID, eventName string) (*models.PcoSubscription, error) {
conf := config.Config()
opts := options.FindOne()
res := db.client.Database(conf.Mongo.EntDb).Collection(conf.Mongo.EntCol).FindOne(context.Background(), bson.M{"user_id": userId, "obj_info.ent": models.PCO_SUBSCRIPTION_TYPE, "details.name": eventName}, opts)
if res.Err() != nil {
if res.Err() == mongo.ErrNoDocuments {
return nil, nil
}
return nil, res.Err()
}
subscription := &models.PcoSubscription{}
err := res.Decode(subscription)
if err != nil {
return nil, err
}
return subscription, nil
}
// Okay so learned something here. Interfaces are determined implemented for the type a method is related to.
// This function is not implemented for DB it is implemented for *DB and that is important
func (db *DB) SaveSubscriptionsForUser(userId primitive.ObjectID, subscriptions ...webhooks.Subscription) error {
mods := make([]*models.PcoSubscription, 0, len(subscriptions))
for _, sub := range subscriptions {
mods = append(mods, &models.PcoSubscription{
UserId: userId,
Details: &sub,
})
}
return saveModels(db, mods...)
}

152
ui/db/token_source.go Normal file
View File

@ -0,0 +1,152 @@
package db
import (
"context"
"errors"
"time"
"git.preston-baxter.com/Preston_PLB/capstone/frontend-service/config"
"git.preston-baxter.com/Preston_PLB/capstone/frontend-service/db/models"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo"
"go.mongodb.org/mongo-driver/mongo/options"
"golang.org/x/oauth2"
)
type VendorTokenSource struct {
db *DB
vendor *models.VendorAccount
}
func (db *DB) NewVendorTokenSource(vendor *models.VendorAccount) *VendorTokenSource {
return &VendorTokenSource{db: db, vendor: vendor}
}
// Returns refreshed token from vendor account
// Not threadsafe, please wrap in a oauth2.RefreshToken
func (ts *VendorTokenSource) Token() (*oauth2.Token, error) {
conf := config.Config()
//get locking collection
col := ts.db.client.Database(conf.Mongo.LockDb).Collection(conf.Mongo.LockCol)
//Define lock
token_lock := &models.TokenLock{
VendorId: ts.vendor.MongoId(),
TokenId: ts.vendor.OauthCredentials.RefreshToken,
Refreshed: false,
}
//Don't forget to create the mongo id
token_lock.MongoId()
token_lock.UpdateObjectInfo()
//try and aquire lock
opts := options.InsertOne()
_, err := col.InsertOne(context.Background(), token_lock, opts)
if err != nil {
//If we didn't get the lock. Wait until whoever did refreshed the token
if mongo.IsDuplicateKeyError(err) {
err = ts.waitForToken(token_lock)
if err != nil {
return nil, err
}
//get new vendorAccount
va, err := ts.db.FindVendorAccountById(ts.vendor.MongoId())
if err != nil {
return nil, err
}
//re-assign vendor account. Let go garbage collector handle the rest
ts.vendor = va
return ts.vendor.Token(), nil
}
//other error return nil
return nil, err
}
//Refresh token we have the lock
token, err := oauth2.RefreshToken(context.Background(), conf.Vendors[ts.vendor.Name].OauthConfig(), ts.vendor.OauthCredentials.RefreshToken)
if err != nil {
return token, err
}
//update vendor
ts.vendor.OauthCredentials.RefreshToken = token.RefreshToken
//save vendor
err = ts.db.SaveModel(ts.vendor)
if err != nil {
return nil, err
}
//release lock
updateOpts := options.Update()
_, err = col.UpdateByID(context.Background(), token_lock.MongoId(), bson.M{"$set": bson.M{"refreshed": true}}, updateOpts)
if err != nil {
return nil, err
}
return token, nil
}
// Allow us to check for kind of error at the end
var TokenWaitExpired error = errors.New("Waiting for token to refresh took too long")
// Used to extract the token lock that was updated when the change stream alerts
type tokenLockChangeEvent struct {
TokenLock *models.TokenLock `bson:"fullDocument"`
}
func (ts *VendorTokenSource) waitForToken(tl *models.TokenLock) error {
conf := config.Config()
//get locking collection
col := ts.db.client.Database(conf.Mongo.LockDb).Collection(conf.Mongo.LockCol)
//Define timeoutfunction
ctx, cancelFunc := context.WithTimeout(context.Background(), 10*time.Second)
defer cancelFunc()
//Get change stream that looks for our token lock
opts := options.ChangeStream().SetFullDocument(options.UpdateLookup)
changeStream, err := col.Watch(ctx, mongo.Pipeline{
{{Key: "$match", Value: bson.D{{Key: "fullDocument.vendor_id", Value: tl.VendorId}, {Key: "fullDocument.token_id", Value: tl.TokenId}}}},
}, opts)
if err != nil {
return err
}
defer changeStream.Close(context.Background())
for changeStream.Next(ctx) {
var tl_event tokenLockChangeEvent
err := changeStream.Decode(&tl_event)
if err != nil {
return err
}
if tl_event.TokenLock.Refreshed {
return nil
}
}
//We waited to long check if its refreshed and carry on
res := col.FindOne(context.Background(), bson.M{"token_id": tl.TokenId})
if res.Err() != nil {
return errors.Join(TokenWaitExpired, res.Err())
}
err = res.Decode(tl)
if err != nil {
return errors.Join(TokenWaitExpired, res.Err())
}
if tl.Refreshed {
return nil
}
return TokenWaitExpired
}

108
ui/db/token_source_test.go Normal file
View File

@ -0,0 +1,108 @@
package db
import (
"fmt"
"net/http"
"net/http/httptest"
"sync"
"testing"
"git.preston-baxter.com/Preston_PLB/capstone/frontend-service/config"
"go.mongodb.org/mongo-driver/bson/primitive"
"golang.org/x/oauth2"
)
func newConf(url string) *oauth2.Config {
return &oauth2.Config{
ClientID: "CLIENT_ID",
ClientSecret: "CLIENT_SECRET",
RedirectURL: "REDIRECT_URL",
Scopes: []string{"scope1", "scope2"},
Endpoint: oauth2.Endpoint{
AuthURL: url + "/auth",
TokenURL: url + "/token",
},
}
}
func TestRefreshToken(t *testing.T) {
config.Init()
conf := config.Config()
client1, err := NewClient(conf.Mongo.Uri)
if err != nil {
t.Fatal(err)
}
client2, err := NewClient(conf.Mongo.Uri)
if err != nil {
t.Fatal(err)
}
count := 0
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"access_token":"ACCESS_TOKEN", "scope": "user", "token_type": "bearer", "refresh_token": "NEW_REFRESH_TOKEN"}`))
count += 1
}))
defer ts.Close()
conf.Vendors["test"].AuthUri = fmt.Sprintf("%s/auth", ts.URL)
conf.Vendors["test"].TokenUri = fmt.Sprintf("%s/auth", ts.URL)
//tkr := conf.TokenSource(context.Background(), &oauth2.Token{RefreshToken: "OLD_REFRESH_TOKEN"})
id, err := primitive.ObjectIDFromHex("6560ecc57567b3806c5dbaf3")
if err != nil {
t.Fatal(err)
}
va1, err := client1.FindVendorAccountById(id)
if err != nil {
t.Fatal(err)
}
va2, err := client2.FindVendorAccountById(id)
if err != nil {
t.Fatal(err)
}
tkr1 := client1.NewVendorTokenSource(va1)
tkr2 := client2.NewVendorTokenSource(va2)
var tk1 *oauth2.Token
var tk2 *oauth2.Token
wg := new(sync.WaitGroup)
wg.Add(2)
go func(tkr oauth2.TokenSource, wg *sync.WaitGroup) {
defer wg.Done()
var err error
tk1, err = tkr.Token()
if err != nil {
t.Errorf("got err = %v; want none", err)
return
}
}(tkr1, wg)
go func(tkr oauth2.TokenSource, wg *sync.WaitGroup) {
defer wg.Done()
var err error
tk2, err = tkr.Token()
if err != nil {
t.Errorf("got err = %v; want none", err)
return
}
}(tkr2, wg)
wg.Wait()
if count != 1 {
t.Fatalf("Count = %d. Should of only hit the endpoint once.", count)
return
}
if tk1.RefreshToken != tk2.RefreshToken && tk1.RefreshToken != "NEW_REFRESH_TOKEN" {
t.Fatalf("%s != %s != NEW_REFRESH_TOKN", tk1.RefreshToken, tk2.RefreshToken)
return
}
}

86
ui/db/user.go Normal file
View File

@ -0,0 +1,86 @@
package db
import (
"context"
"git.preston-baxter.com/Preston_PLB/capstone/frontend-service/config"
"git.preston-baxter.com/Preston_PLB/capstone/frontend-service/db/models"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.mongodb.org/mongo-driver/mongo"
"go.mongodb.org/mongo-driver/mongo/options"
)
// seraches for a single user by email address
func (db *DB) FindUserByEmail(email string) (*models.User, error) {
conf := config.Config()
opts := options.FindOne()
res := db.client.Database(conf.Mongo.EntDb).Collection(conf.Mongo.EntCol).FindOne(context.Background(), bson.M{"email": email, "obj_info.ent": models.USER_TYPE}, opts)
if res.Err() != nil {
if res.Err() == mongo.ErrNoDocuments {
return nil, nil
}
return nil, res.Err()
}
user := &models.User{}
err := res.Decode(user)
if err != nil {
return nil, err
}
return user, nil
}
// find user by its unique id
func (db *DB) FindUserById(id string) (*models.User, error) {
conf := config.Config()
opts := options.FindOne()
objId, err := primitive.ObjectIDFromHex(id)
if err != nil {
return nil, err
}
res := db.client.Database(conf.Mongo.EntDb).Collection(conf.Mongo.EntCol).FindOne(context.Background(), bson.M{"_id": objId, "obj_info.ent": models.USER_TYPE}, opts)
if res.Err() != nil {
if res.Err() == mongo.ErrNoDocuments {
return nil, nil
}
return nil, res.Err()
}
user := &models.User{}
err = res.Decode(user)
if err != nil {
return nil, err
}
return user, nil
}
// returns all users
func (db *DB) FindAllUsers() ([]models.User, error) {
conf := config.Config()
opts := options.Find()
res, err := db.client.Database(conf.Mongo.EntDb).Collection(conf.Mongo.EntCol).Find(context.Background(), bson.M{"obj_info.ent": models.USER_TYPE}, opts)
if err != nil {
if err == mongo.ErrNoDocuments {
return nil, nil
}
return nil, res.Err()
}
users := []models.User{}
err = res.All(context.Background(), users)
if err != nil {
return nil, err
}
return users, nil
}

78
ui/db/vendors.go Normal file
View File

@ -0,0 +1,78 @@
package db
import (
"context"
"git.preston-baxter.com/Preston_PLB/capstone/frontend-service/config"
"git.preston-baxter.com/Preston_PLB/capstone/frontend-service/db/models"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.mongodb.org/mongo-driver/mongo"
"go.mongodb.org/mongo-driver/mongo/options"
)
// return all vendor accounts for a user
func (db *DB) FindAllVendorAccountsByUser(userId primitive.ObjectID) ([]models.VendorAccount, error) {
conf := config.Config()
opts := options.Find()
res, err := db.client.Database(conf.Mongo.EntDb).Collection(conf.Mongo.EntCol).Find(context.Background(), bson.M{"user_id": userId, "obj_info.ent": models.VENDOR_ACCOUNT_TYPE}, opts)
if err != nil {
if err == mongo.ErrNoDocuments {
return nil, nil
}
return nil, err
}
vendors := []models.VendorAccount{}
err = res.All(context.Background(), &vendors)
if err != nil {
return nil, err
}
return vendors, nil
}
// find vendor for user by name
func (db *DB) FindVendorAccountByUser(userId primitive.ObjectID, name string) (*models.VendorAccount, error) {
conf := config.Config()
opts := options.FindOne()
res := db.client.Database(conf.Mongo.EntDb).Collection(conf.Mongo.EntCol).FindOne(context.Background(), bson.M{"user_id": userId, "name": name, "obj_info.ent": models.VENDOR_ACCOUNT_TYPE}, opts)
if res.Err() != nil {
if res.Err() == mongo.ErrNoDocuments {
return nil, nil
}
return nil, res.Err()
}
vendor := &models.VendorAccount{}
err := res.Decode(vendor)
if err != nil {
return nil, err
}
return vendor, nil
}
// find vendoraccount by its unique id
func (db *DB) FindVendorAccountById(vendorId primitive.ObjectID) (*models.VendorAccount, error) {
conf := config.Config()
opts := options.FindOne()
res := db.client.Database(conf.Mongo.EntDb).Collection(conf.Mongo.EntCol).FindOne(context.Background(), bson.M{"_id": vendorId, "obj_info.ent": models.VENDOR_ACCOUNT_TYPE}, opts)
if res.Err() != nil {
if res.Err() == mongo.ErrNoDocuments {
return nil, nil
}
return nil, res.Err()
}
vendor := &models.VendorAccount{}
err := res.Decode(vendor)
if err != nil {
return nil, err
}
return vendor, nil
}

33
ui/db/youtube.go Normal file
View File

@ -0,0 +1,33 @@
package db
import (
"context"
"git.preston-baxter.com/Preston_PLB/capstone/frontend-service/config"
"git.preston-baxter.com/Preston_PLB/capstone/frontend-service/db/models"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.mongodb.org/mongo-driver/mongo"
"go.mongodb.org/mongo-driver/mongo/options"
)
func (db *DB) FindAllBroadcastsByCorrelationId(userId primitive.ObjectID, correlationId string) ([]models.YoutubeBroadcast, error) {
conf := config.Config()
opts := options.Find()
res, err := db.client.Database(conf.Mongo.EntDb).Collection(conf.Mongo.EntCol).Find(context.Background(), bson.M{"user_id": userId, "obj_info.ent": models.YOUTUBE_BROADCAST_TYPE, "correlation_id": correlationId}, opts)
if err != nil {
if err == mongo.ErrNoDocuments {
return nil, nil
}
return nil, err
}
vendors := []models.YoutubeBroadcast{}
err = res.All(context.Background(), &vendors)
if err != nil {
return nil, err
}
return vendors, nil
}

View File

@ -0,0 +1,33 @@
version: "3.8"
services:
mongo1:
image: mongo:latest
container_name: mongo1
command: ["--replSet", "my-replica-set", "--bind_ip_all", "--port", "30001"]
volumes:
- /etc/capstone/db1:/data/db
ports:
- 30001:30001
healthcheck:
test: test $$(echo "rs.initiate({_id:'my-replica-set',members:[{_id:0,host:\"mongo1:30001\"},{_id:1,host:\"mongo2:30002\"},{_id:2,host:\"mongo3:30003\"}]}).ok || rs.status().ok" | mongosh --port 30001 --quiet) -eq 1
interval: 10s
start_period: 30s
mongo2:
image: mongo:latest
container_name: mongo2
command: ["--replSet", "my-replica-set", "--bind_ip_all", "--port", "30002"]
volumes:
- /etc/capstone/db2:/data/db
ports:
- 30002:30002
mongo3:
image: mongo:latest
container_name: mongo3
command: ["--replSet", "my-replica-set", "--bind_ip_all", "--port", "30003"]
volumes:
- /etc/capstone/db3:/data/db
ports:
- 30003:30003

90
ui/go.mod Normal file
View File

@ -0,0 +1,90 @@
module git.preston-baxter.com/Preston_PLB/capstone/frontend-service
go 1.21
toolchain go1.21.4
require (
git.preston-baxter.com/Preston_PLB/capstone/webhook-service v0.0.0-00010101000000-000000000000
github.com/a-h/templ v0.2.408
github.com/gin-contrib/cors v1.4.0
github.com/gin-gonic/gin v1.9.1
github.com/golang-jwt/jwt/v5 v5.0.0
github.com/sirupsen/logrus v1.9.3
github.com/spf13/viper v1.17.0
go.mongodb.org/mongo-driver v1.13.0
golang.org/x/crypto v0.15.0
golang.org/x/oauth2 v0.14.0
google.golang.org/api v0.150.0
)
require (
cloud.google.com/go/compute v1.23.1 // indirect
cloud.google.com/go/compute/metadata v0.2.3 // indirect
github.com/bytedance/sonic v1.9.1 // indirect
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
github.com/fsnotify/fsnotify v1.6.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.14.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/golang/protobuf v1.5.3 // indirect
github.com/golang/snappy v0.0.4 // indirect
github.com/google/jsonapi v1.0.0 // indirect
github.com/google/s2a-go v0.1.7 // indirect
github.com/google/uuid v1.4.0 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.2 // indirect
github.com/googleapis/gax-go/v2 v2.12.0 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.17.0 // indirect
github.com/klauspost/cpuid/v2 v2.2.4 // indirect
github.com/leodido/go-urn v1.2.4 // indirect
github.com/magiconair/properties v1.8.7 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect
github.com/miekg/dns v1.1.57 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe // indirect
github.com/pelletier/go-toml/v2 v2.1.0 // indirect
github.com/sagikazarmark/locafero v0.3.0 // indirect
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
github.com/spf13/afero v1.10.0 // indirect
github.com/spf13/cast v1.5.1 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.11 // indirect
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
github.com/xdg-go/scram v1.1.2 // indirect
github.com/xdg-go/stringprep v1.0.4 // indirect
github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d // indirect
go.opencensus.io v0.24.0 // indirect
go.uber.org/atomic v1.10.0 // indirect
go.uber.org/multierr v1.9.0 // indirect
golang.org/x/arch v0.3.0 // indirect
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect
golang.org/x/mod v0.12.0 // indirect
golang.org/x/net v0.18.0 // indirect
golang.org/x/sync v0.5.0 // indirect
golang.org/x/sys v0.14.0 // indirect
golang.org/x/text v0.14.0 // indirect
golang.org/x/tools v0.13.0 // indirect
google.golang.org/appengine v1.6.7 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20231030173426-d783a09b4405 // indirect
google.golang.org/grpc v1.59.0 // indirect
google.golang.org/protobuf v1.31.0 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
replace git.preston-baxter.com/Preston_PLB/capstone/webhook-service => ../service
replace golang.org/x/oauth2 => ../libs/oauth2
replace github.com/google/jsonapi => ../libs/jsonapi

1703
ui/go.sum Normal file

File diff suppressed because it is too large Load Diff

29
ui/main.go Normal file
View File

@ -0,0 +1,29 @@
package main
import (
"fmt"
"os"
"git.preston-baxter.com/Preston_PLB/capstone/frontend-service/config"
"git.preston-baxter.com/Preston_PLB/capstone/frontend-service/controllers"
"github.com/gin-gonic/gin"
)
func main() {
config.Init()
r := gin.Default()
controllers.BuildRouter(r)
var addr string
if port := os.Getenv("PORT"); port != "" {
addr = fmt.Sprintf("0.0.0.0:%s", port)
} else {
addr = "0.0.0.0:8080"
}
err := r.Run(addr)
if err != nil {
panic(err)
}
}

1009
ui/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

8
ui/package.json Normal file
View File

@ -0,0 +1,8 @@
{
"dependencies": {
"@material-tailwind/html": "^2.0.0"
},
"devDependencies": {
"tailwindcss": "^3.3.2"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

BIN
ui/static/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

10
ui/tailwind.config.js Normal file
View File

@ -0,0 +1,10 @@
/** @type {import('tailwindcss').Config} */
const withMT = require("@material-tailwind/html/utils/withMT");
module.exports = withMT({
content: ["./templates/*.templ"],
plugins: [],
theme: {
extend: {},
}
})

3
ui/tailwind/index.css Normal file
View File

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

View File

@ -0,0 +1,556 @@
package templates
import (
"fmt"
"strconv"
"git.preston-baxter.com/Preston_PLB/capstone/frontend-service/db/models"
)
func hasVendor(name string, vendors []models.VendorAccount) bool {
for _, vendor := range vendors {
if vendor.Name == name {
return true
}
}
return false
}
func hasPco(vendors []models.VendorAccount) bool {
return hasVendor(models.PCO_VENDOR_NAME, vendors)
}
func hasYoutube(vendors []models.VendorAccount) bool {
return hasVendor(models.YOUTUBE_VENDOR_NAME, vendors)
}
templ DashboardPage(user *models.User, vendorAccounts []models.VendorAccount, actionMappings []models.ActionMapping) {
<!DOCTYPE html>
<html>
@Head("Dashboard")
<body class="text-blueGray-700 antialiased">
<div
id="add-action-modal"
aria-hidden="false"
tabindex="-1"
class="transition-all hidden"
></div>
<div id="root" class="h-screen overflow-scroll">
@DashboardNav(user)
@DashboardContent(user, vendorAccounts, actionMappings)
</div>
@DashboardFooter()
</body>
@DashboardScript()
@toggleDropdown()
</html>
}
templ DashboardNav(user *models.User) {
<nav
class="md:left-0 md:block md:fixed md:top-0 md:bottom-0 md:overflow-y-auto md:flex-row md:flex-nowrap md:overflow-hidden shadow-xl bg-white flex flex-wrap items-center justify-between relative md:w-64 z-10 py-4 px-6"
>
<div
class="md:flex-col md:items-stretch md:min-h-full md:flex-nowrap px-0 flex flex-wrap items-center justify-between w-full mx-auto"
>
<button
class="cursor-pointer text-black opacity-50 md:hidden px-3 py-1 text-xl leading-none bg-transparent rounded border border-solid border-transparent"
type="button"
onclick="toggleNavbar('example-collapse-sidebar')"
>
<i class="fas fa-bars"></i>
</button>
<a
class="md:block text-left md:pb-2 text-blueGray-600 mr-0 inline-block whitespace-nowrap text-sm uppercase font-bold p-4 px-0"
href="javascript:void(0)"
>
{ user.Email }
</a>
<div
class="md:flex md:flex-col md:items-stretch md:opacity-100 md:relative md:mt-4 md:shadow-none shadow absolute top-0 left-0 right-0 z-40 overflow-y-auto overflow-x-hidden h-auto items-center flex-1 rounded hidden"
id="example-collapse-sidebar"
>
<div
class="md:min-w-full md:hidden block pb-4 mb-4 border-b border-solid border-blueGray-200"
>
<div class="flex flex-wrap">
<div class="w-6/12">
<a
class="md:block text-left md:pb-2 text-blueGray-600 mr-0 inline-block whitespace-nowrap text-sm uppercase font-bold p-4 px-0"
href="javascript:void(0)"
>
{ user.Email }
</a>
</div>
<div class="w-6/12 flex justify-end">
<button
type="button"
class="cursor-pointer text-black opacity-50 md:hidden px-3 py-1 text-xl leading-none bg-transparent rounded border border-solid border-transparent"
onclick="toggleNavbar('example-collapse-sidebar')"
>
<i class="fas fa-times"></i>
</button>
</div>
</div>
</div>
<form class="mt-6 mb-4 md:hidden">
<div class="mb-3 pt-0">
<input
type="text"
placeholder="Search"
class="px-3 py-2 h-12 border border-solid border-blueGray-500 placeholder-blueGray-300 text-blueGray-600 bg-white rounded text-base leading-snug shadow-none outline-none focus:outline-none w-full font-normal"
/>
</div>
</form>
<ul class="md:flex-col md:min-w-full flex flex-col list-none">
@DashboardNavItem("fa-tv", "Dashboard", "/dashboard", true)
@DashboardNavItem("fa-chart-bar", "Events", "/dashboard/events", true)
<hr/>
@DashboardNavItem("fa-newspaper", "Home Page", "/", true)
@DashboardNavItem("fa-user-circle", "Profile (SOON)", "#", false)
</ul>
</div>
</div>
</nav>
}
templ DashboardNavItem(icon, name, link string, enabled bool) {
<li class="items-center">
<a
if enabled {
class="text-blue-500 hover:text-blue-600 text-xs uppercase py-3 font-bold block"
href={ templ.URL(link) }
} else {
class="text-blueGray-300 text-xs uppercase py-3 font-bold block"
href="#"
}
href="{ link }"
>
<i
if enabled {
class={ fmt.Sprintf("fas %s opacity-75 mr-2 text-sm", icon) }
} else {
class={ fmt.Sprintf("fas %s text-blueGray-300 mr-2 text-sm", icon) }
}
></i>{ name }
</a>
</li>
}
//Break this up
templ DashboardContentNav(user *models.User) {
<nav
class="absolute top-0 left-0 w-full z-10 bg-transparent md:flex-row md:flex-nowrap md:justify-start flex items-center p-4"
>
<div
class="w-full mx-autp items-center flex justify-between md:flex-nowrap flex-wrap md:px-10 px-4"
>
<a
class="text-white text-sm uppercase hidden lg:inline-block font-semibold"
href="./index.html"
>Capstone</a>
<form
class="hidden flex-row flex-wrap items-center lg:ml-auto mr-3"
>
<div class="relative flex w-full flex-wrap items-stretch">
<span
class="z-10 h-full leading-snug font-normal text-center text-blueGray-300 absolute bg-transparent rounded text-base items-center justify-center w-8 pl-3 py-3"
><i class="fas fa-search"></i></span>
<input
type="text"
placeholder="Search here..."
class="border-0 px-3 py-3 placeholder-blueGray-300 text-blueGray-600 relative bg-white rounded text-sm shadow outline-none focus:outline-none focus:ring w-full pl-10"
/>
</div>
</form>
<ul
class="hidden flex-col md:flex-row list-none items-center hidden md:flex"
>
<a class="hidden text-blueGray-500 block" href="#pablo" onclick="openDropdown(event,'user-dropdown')">
<div class="items-center flex">
<span
class="w-12 h-12 text-sm text-white bg-blueGray-200 inline-flex items-center justify-center rounded-full"
>
</span>
</div>
</a>
<div
class="hidden bg-white text-base z-50 float-left py-2 list-none text-left rounded shadow-lg mt-1"
style="min-width: 12rem;"
id="user-dropdown"
>
<a
href="#pablo"
class="text-sm py-2 px-4 font-normal block w-full whitespace-nowrap bg-transparent text-blueGray-700"
>Action</a><a
href="#pablo"
class="text-sm py-2 px-4 font-normal block w-full whitespace-nowrap bg-transparent text-blueGray-700"
>Another action</a><a
href="#pablo"
class="text-sm py-2 px-4 font-normal block w-full whitespace-nowrap bg-transparent text-blueGray-700"
>Something else here</a>
<div class="h-0 my-2 border border-solid border-blueGray-100"></div>
<a
href="#pablo"
class="text-sm py-2 px-4 font-normal block w-full whitespace-nowrap bg-transparent text-blueGray-700"
>Seprated link</a>
</div>
</ul>
</div>
</nav>
}
templ DashboardCardLoader(kind string) {
<div class="w-full lg:w-6/12 xl:w-6/12 px-4" hx-get={ fmt.Sprintf("/dashboard/components/metric_card?metric=%s", kind) } hx-trigger="load" hx-swap="outerHTML">
<div class="relative flex flex-col min-w-0 break-words bg-white rounded mb-6 xl:mb-0 shadow-lg opacity-50">
<div class="flex-auto p-4">
<div class="flex flex-wrap">
<div class="relative w-full pr-4 max-w-full flex-grow flex-1">
<h5 class="text-blueGray-400 uppercase font-bold text-xs">
---
</h5>
<span class="font-semibold text-xl text-blueGray-700">
---
</span>
</div>
<div class="relative w-auto pl-4 flex-initial">
<div class="text-white p-3 text-center inline-flex items-center justify-center w-12 h-12 shadow-lg rounded-full bg-blue-300">
<i class="far fa-chart-bar"></i>
</div>
</div>
</div>
<p class="text-sm text-blueGray-400 mt-4">
<span class="text-emerald-500 mr-2">
---
</span>
<span class="whitespace-nowrap">
---
</span>
</p>
</div>
@spinnerCentered()
</div>
</div>
}
templ DashboardCard(title, primaryVal, secondaryVal, subtitle string) {
<div class="w-full lg:w-6/12 xl:w-6/12 px-4">
<div class="relative flex flex-col min-w-0 break-words bg-white rounded mb-6 xl:mb-0 shadow-lg">
<div class="flex-auto p-4">
<div class="flex flex-wrap">
<div class="relative w-full pr-4 max-w-full flex-grow flex-1">
<h5 class="text-blueGray-400 uppercase font-bold text-xs">
{ title }
</h5>
<span class="font-semibold text-xl text-blueGray-700">
{ primaryVal }
</span>
</div>
<div class="relative w-auto pl-4 flex-initial">
<div class="text-white p-3 text-center inline-flex items-center justify-center w-12 h-12 shadow-lg rounded-full bg-blue-300">
<i class="far fa-chart-bar"></i>
</div>
</div>
</div>
<p class="text-sm text-blueGray-400 mt-4">
<span class="text-emerald-500 mr-2">
{ secondaryVal }
</span>
<span class="whitespace-nowrap">
{ subtitle }
</span>
</p>
</div>
</div>
</div>
}
templ DashboardVendorDropDown() {
<div class="flex flex-wrap float-right">
<div class="w-full sm:w-6/12 md:w-4/12 px-4">
<div class="relative inline-flex align-middle w-full">
<button class="text-white font-bold uppercase text-sm px-6 py-3 rounded shadow hover:shadow-lg outline-none focus:outline-none mr-1 mb-1 bg-blueGray-500 ease-linear transition-all duration-150" type="button" onclick="openDropdown(event,'dropdown-id')">
+
</button>
<div class="hidden bg-white text-base z-50 float-left py-2 list-none text-left rounded shadow-lg mt-1" style="min-width:12rem" id="dropdown-id">
<form action="/vendor/youtube/initiate" method="POST">
<button type="submit" class="text-sm align-left py-2 px-4 font-normal block w-full whitespace-nowrap bg-transparent text-blueGray-700">
Youtube
</button>
</form>
<form action="/vendor/pco/initiate" method="POST">
<button type="submit" class="text-sm align-left py-2 px-4 font-normal block w-full whitespace-nowrap bg-transparent text-blueGray-700">
Planning Center
</button>
</form>
</div>
</div>
</div>
</div>
}
templ DashboardVendorWidget(vendors []models.VendorAccount) {
<div class="w-full xl:w-8/12 mb-12 xl:mb-0 px-4">
<div class="relative flex flex-col min-w-0 break-words bg-white w-full mb-6 shadow-lg rounded">
<div class="rounded-t mb-0 px-4 py-3 border-0">
<div class="flex flex-wrap items-center">
<div class="relative w-full px-4 max-w-full flex-grow flex-1">
<h3 class="font-semibold text-base text-blueGray-700">
Vendors
</h3>
</div>
<div class="relative w-full px-4 max-w-full flex-grow flex-1 text-right">
@DashboardVendorDropDown()
</div>
</div>
</div>
<div class="block w-full overflow-x-auto">
<!-- Projects table -->
<table class="items-center w-full bg-transparent border-collapse">
<thead>
<tr>
<th class="px-6 bg-blueGray-50 text-blueGray-500 align-middle border border-solid border-blueGray-100 py-3 text-xs uppercase border-l-0 border-r-0 whitespace-nowrap font-semibold text-left">
Name
</th>
<th class="px-6 bg-blueGray-50 text-blueGray-500 align-middle border border-solid border-blueGray-100 py-3 text-xs uppercase border-l-0 border-r-0 whitespace-nowrap font-semibold text-left">
Status
</th>
</tr>
</thead>
<tbody>
if len(vendors) == 0 {
<tr>
<th class="border-t-0 px-6 align-middle border-l-0 border-r-0 text-xs whitespace-nowrap p-4 text-left">
No accounts are available. Click + to add one
</th>
</tr>
} else {
for _, vendor := range vendors {
<tr>
<th class="border-t-0 px-6 align-middle border-l-0 border-r-0 text-xs whitespace-nowrap p-4 text-left">
{ vendor.Name }
</th>
<th class="border-t-0 px-6 align-middle border-l-0 border-r-0 text-xs whitespace-nowrap p-4 text-left">
if vendor.OauthCredentials != nil && vendor.OauthCredentials.AccessToken != "" {
Active
} else {
<button>Log in</button>
}
</th>
</tr>
}
}
</tbody>
</table>
</div>
</div>
</div>
}
templ DashboardActionModalForm(vendors []models.VendorAccount) {
<div class="relative p-6 flex-auto">
<form class="space-y-4 text-gray-700" action="/dashboard/action/add" method="POST">
<div class="flex flex-wrap -mx-2 space-y-4 md:space-y-0">
<div class="w-full">
<div class="relative inline-block w-full text-gray-700">
<select class="w-full h-10 pl-3 pr-6 text-base placeholder-gray-600 border rounded-lg appearance-none focus:shadow-outline" placeholder="Choose action source" name="source">
if hasPco(vendors) {
<option value="pco.plan">Plan</option>
<option value="calendar" disabled>Calendar</option>
} else {
<option value="nil">None Available</option>
}
</select>
</div>
</div>
</div>
<div class="flex flex-wrap -mx-2 space-y-4 md:space-y-0">
<div class="w-full">
<div class="relative inline-block w-full text-gray-700">
<select class="w-full h-10 pl-3 pr-6 text-base placeholder-gray-600 border rounded-lg appearance-none focus:shadow-outline" placeholder="Choose action source" name="action">
if hasYoutube(vendors) {
<option value="youtube.livestream">Livestream</option>
<option value="video" disabled>Video</option>
} else {
<option value="nil">None Available</option>
}
</select>
</div>
</div>
</div>
<div class="flex items-center justify-end p-6 border-t border-solid border-blueGray-200 rounded-b">
<button class="text-gray-400 background-transparent font-bold uppercase px-6 py-2 text-sm outline-none focus:outline-none mr-1 mb-1 ease-linear transition-all duration-150" type="button" onclick="toggleModal('add-action-modal')">
Close
</button>
<button class="bg-blue-700 text-white active:bg-blue-300 font-bold uppercase text-sm px-6 py-3 rounded shadow hover:shadow-lg outline-none focus:outline-none mr-1 mb-1 ease-linear transition-all duration-150" type="submit">
Save Changes
</button>
</div>
</form>
</div>
}
templ DashboardActionModal(vendors []models.VendorAccount) {
<div class="transition-all ease-in-out overflow-x-hidden overflow-y-auto fixed flex inset-0 z-50 outline-none focus:outline-none justify-center items-center" id="add-action-modal">
<div class="relative w-auto my-6 mx-auto max-w-3xl">
<!--content-->
<div class="border-0 rounded-lg shadow-lg relative flex flex-col w-full bg-white outline-none focus:outline-none">
<!--header-->
<div class="flex items-start justify-between p-5 border-b border-solid border-blueGray-200 rounded-t">
<h3 class="text-3xl font-semibold">
Modal Title
</h3>
<button class="p-1 ml-auto bg-transparent border-0 float-right text-3xl leading-none font-semibold outline-none focus:outline-none" onclick="toggleModal('add-action-modal')">
<span class="bg-transparent text-gray-500 h-6 w-6 text-2xl block outline-none focus:outline-none">
×
</span>
</button>
</div>
<!--body-->
@DashboardActionModalForm(vendors)
<!--footer-->
</div>
</div>
</div>
<div class="opacity-25 fixed flex inset-0 z-40 bg-black" id="add-action-modal-backdrop"></div>
}
templ DashboardActionDropDown() {
<button
hx-get="/dashboard/forms/addAction"
hx-target="#add-action-modal"
hx-swap="outerHTML"
class="bg-blue-500 text-white active:bg-blue-600 font-bold uppercase text-sm px-6 py-3 rounded shadow hover:shadow-lg outline-none focus:outline-none mr-1 mb-1 ease-linear transition-all duration-150"
type="button"
>
Add Action
</button>
}
templ DashboardActionEditButton(action *models.ActionMapping) {
}
templ DashboardActionsWidget(actions []models.ActionMapping) {
<div class="w-full xl:w-8/12 mb-12 xl:mb-0 px-4">
<div class="relative flex flex-col min-w-0 break-words bg-white w-full mb-6 shadow-lg rounded">
<div class="rounded-t mb-0 px-4 py-3 border-0">
<div class="flex flex-wrap items-center">
<div class="relative w-full px-4 max-w-full flex-grow flex-1">
<h3 class="font-semibold text-base text-blueGray-700">
Vendors
</h3>
</div>
<div class="relative w-full px-4 max-w-full flex-grow flex-1 text-right">
@DashboardActionDropDown()
</div>
</div>
</div>
<div class="block w-full overflow-x-auto">
<!-- Projects table -->
<table class="items-center w-full bg-transparent border-collapse">
<thead>
<tr>
<th class="px-6 bg-blueGray-50 text-blueGray-500 align-middle border border-solid border-blueGray-100 py-3 text-xs uppercase border-l-0 border-r-0 whitespace-nowrap font-semibold text-left">
Id
</th>
<th class="px-6 bg-blueGray-50 text-blueGray-500 align-middle border border-solid border-blueGray-100 py-3 text-xs uppercase border-l-0 border-r-0 whitespace-nowrap font-semibold text-left">
Event Source
</th>
<th class="px-6 bg-blueGray-50 text-blueGray-500 align-middle border border-solid border-blueGray-100 py-3 text-xs uppercase border-l-0 border-r-0 whitespace-nowrap font-semibold text-left">
Action Destination
</th>
<th class="px-6 bg-blueGray-50 text-blueGray-500 align-middle border border-solid border-blueGray-100 py-3 text-xs uppercase border-l-0 border-r-0 whitespace-nowrap font-semibold text-left">
Action
</th>
</tr>
</thead>
<tbody>
if len(actions) == 0 {
<tr>
<th class="border-t-0 px-6 align-middle border-l-0 border-r-0 text-xs whitespace-nowrap p-4 text-left">
No actions are available. Click + to add one
</th>
</tr>
} else {
for index, action := range actions {
<tr>
<th class="border-t-0 px-6 align-middle border-l-0 border-r-0 text-xs whitespace-nowrap p-4 text-left">
{ strconv.Itoa(index) }
</th>
<th class="border-t-0 px-6 align-middle border-l-0 border-r-0 text-xs whitespace-nowrap p-4 text-left">
{ action.SourceEvent.Key }
</th>
<th class="border-t-0 px-6 align-middle border-l-0 border-r-0 text-xs whitespace-nowrap p-4 text-left">
{ action.Action.VendorName }
</th>
<th class="border-t-0 px-6 align-middle border-l-0 border-r-0 text-xs whitespace-nowrap p-4 text-left"> { action.Action.Type }
</th>
</tr>
}
}
</tbody>
</table>
</div>
</div>
</div>
}
templ DashboardContent(user *models.User, vendorAccounts []models.VendorAccount, actions []models.ActionMapping) {
<noscript>You need to enable JavaScript to run this app.</noscript>
<div class="relative md:ml-64 bg-blueGray-50 h-full">
@DashboardContentNav(user)
<!-- Header -->
<div class="relative bg-blue-600 md:pt-32 pb-32 pt-12">
<div class="px-4 md:px-10 mx-auto w-full">
<div>
<!-- Card stats -->
<div class="flex flex-wrap">
@DashboardCardLoader("events_received")
@DashboardCardLoader("streams_scheduled")
</div>
</div>
</div>
</div>
<div class="px-4 md:px-10 mx-auto w-full -m-24">
<div class="flex flex-wrap">
@DashboardVendorWidget(vendorAccounts)
</div>
<div class="flex flex-wrap">
@DashboardActionsWidget(actions)
</div>
</div>
</div>
}
templ DashboardScript() {
<script type="text/javascript">
function toggleModal(modalID) {
document.getElementById(modalID).classList.toggle("hidden");
document.getElementById(modalID + "-backdrop").classList.toggle("hidden");
document.getElementById(modalID).classList.toggle("flex");
document.getElementById(modalID + "-backdrop").classList.toggle("flex");
}
function toggleNavbar(collapseID) {
document.getElementById(collapseID).classList.toggle("hidden");
document.getElementById(collapseID).classList.toggle("bg-white");
document.getElementById(collapseID).classList.toggle("m-2");
document.getElementById(collapseID).classList.toggle("py-3");
document.getElementById(collapseID).classList.toggle("px-6");
}
/* Function for dropdowns */
function openDropdown(event, dropdownID) {
let element = event.target;
while (element.nodeName !== "A") {
element = element.parentNode;
}
var popper = Popper.createPopper(element, document.getElementById(dropdownID), {
placement: "bottom-end"
});
document.getElementById(dropdownID).classList.toggle("hidden");
document.getElementById(dropdownID).classList.toggle("relative");
}
</script>
}

View File

@ -0,0 +1,180 @@
package templates
import (
"fmt"
"git.preston-baxter.com/Preston_PLB/capstone/frontend-service/db/models"
)
type TableData [][]string
templ EventsPage(user *models.User) {
<!DOCTYPE html>
<html>
@Head("Events")
<body class="text-blueGray-700 antialiased">
<div
id="add-action-modal"
aria-hidden="false"
tabindex="-1"
class="transition-all hidden"
></div>
<div id="root" class="h-screen overflow-scroll">
@DashboardNav(user)
@EventContent(user)
</div>
@DashboardFooter()
</body>
@DashboardScript()
@toggleDropdown()
@updateSearchScript()
</html>
}
var sampleData = [][]string{{"head 1", "head 2"}, {"row 1", "row 1"}, {"row 2", "row 2"}}
var blankData = [][]string{{"head 1", "head 2"}}
templ EventContent(user *models.User) {
<noscript>You need to enable JavaScript to run this app.</noscript>
<div class="relative md:ml-64 bg-blueGray-50">
@DashboardContentNav(user)
<!-- Header -->
<div class="relative bg-blue-600 md:pt-32 pb-32 pt-12">
<div class="px-4 md:px-10 mx-auto w-1/4 h-3/4"></div>
<div class="px-4 md:px-10 mx-auto w-2/4 h-3/4">
<div class="relative flex flex-col min-w-0 break-words bg-white rounded mb-6 xl:mb-0 shadow-lg">
<div class="flex-auto p-4">
<div
class="flex-row flex-wrap items-center lg:ml-auto mr-3"
>
<div class="relative flex w-full flex-wrap items-stretch">
<span
class="z-10 h-full leading-snug font-normal text-center text-blueGray-300 absolute bg-transparent rounded text-base items-center justify-center w-8 pl-3 py-3"
><i class="fas fa-search"></i></span>
<input
id="search_bar"
type="text"
onkeyup="onSearchKeyUp()"
placeholder="Search here..."
class="border-0 px-3 py-3 placeholder-blueGray-300 text-blueGray-600 relative bg-white rounded text-sm shadow outline-none focus:outline-none focus:ring w-full pl-10"
/>
</div>
</div>
</div>
</div>
</div>
<div class="px-4 md:px-10 mx-auto w-1/4 h-3/4"></div>
</div>
<div class="px-4 md:px-10 mx-auto w-full -m-24">
<div class="flex flex-wrap">
<div class="w-full xl:w-6/12 mb-12 xl:mb-0 px-4">
@EventTableWidget("Events", "events_for_user")
</div>
<div class="w-full xl:w-6/12 mb-12 xl:mb-0 px-4">
@EventTableWidget("Actions", "actions_for_user")
</div>
</div>
</div>
</div>
}
templ EventTableWidget(title, table_name string) {
<div class="relative flex flex-col min-w-0 break-words bg-white w-full mb-6 shadow-lg rounded">
<div class="rounded-t mb-0 px-4 py-3 border-0">
<div class="flex flex-wrap items-center">
<div class="relative w-full px-4 max-w-full flex-grow flex-1">
<h3 class="font-semibold text-base text-blueGray-700">
{ title }
</h3>
</div>
</div>
</div>
<div class="block w-full overflow-x-auto">
<!-- Projects table -->
@EventTableDataLoader(table_name)
</div>
</div>
}
templ EventTableDataLoader(table_name string) {
<div class="relative" hx-get={ fmt.Sprintf("/dashboard/events/components/table_data?table_name=%s", table_name) } hx-swap="outerHTML" hx-trigger="load">
@EventTableDataLazy(table_name)
@spinnerCentered()
</div>
}
//hx-get={ fmt.Sprintf("/dashboard/events/components/table_data?table_name=%s", table_name) }
templ EventTableDataLazy(table_name string) {
<table class="items-center opacity-50 w-full bg-transparent border-collapse">
<thead>
<tr>
<th class="px-6 bg-blueGray-50 opacity-50 text-blueGray-500 align-middle border border-solid border-blueGray-100 py-3 text-xs uppercase border-l-0 border-r-0 whitespace-nowrap font-semibold text-left">
Lorem Ipsum
</th>
<th class="px-6 bg-blueGray-50 opacity-50 text-blueGray-500 align-middle border border-solid border-blueGray-100 py-3 text-xs uppercase border-l-0 border-r-0 whitespace-nowrap font-semibold text-left">
Lorem Ipsum
</th>
</tr>
</thead>
<tbody>
<tr>
<th class="border-t-0 px-6 align-middle opacity-50 border-l-0 border-r-0 text-xs whitespace-nowrap p-4 text-left">
No Data Available
</th>
<th class="border-t-0 px-6 align-middle opacity-50 border-l-0 border-r-0 text-xs whitespace-nowrap p-4 text-left">
No Data Available
</th>
</tr>
</tbody>
</table>
}
templ EventTableData(data TableData, table_name string) {
<table class="items-center w-full bg-transparent border-collapse" hx-get={ fmt.Sprintf("/dashboard/events/components/table_data?table_name=%s", table_name) } hx-params="*" hx-trigger="search delay:200ms from:body">
<thead>
<tr>
for _, header := range data[0] {
<th class="px-6 bg-blueGray-50 text-blueGray-500 align-middle border border-solid border-blueGray-100 py-3 text-xs uppercase border-l-0 border-r-0 whitespace-nowrap font-semibold text-left">
{ header }
</th>
}
</tr>
</thead>
<tbody>
if len(data) <= 1 {
<tr>
<th class="border-t-0 px-6 align-middle border-l-0 border-r-0 text-xs whitespace-nowrap p-4 text-left">
No Data Available
</th>
</tr>
} else {
for _, row := range data[1:] {
<tr>
for _, item := range row {
<th class="border-t-0 px-6 align-middle border-l-0 border-r-0 text-xs whitespace-nowrap p-4 text-left">
{ item }
</th>
}
</tr>
}
}
</tbody>
</table>
}
templ updateSearchScript() {
<script>
const searchEvent = new Event("search");
function onSearchKeyUp() {
// console.log("keyup")
document.body.dispatchEvent(searchEvent);
}
document.body.addEventListener('htmx:configRequest', function(evt) {
var query = document.getElementById("search_bar").value;
evt.detail.parameters['filter'] = query // add a new parameter into the request
});
</script>
}

118
ui/templates/footer.templ Normal file
View File

@ -0,0 +1,118 @@
package templates
templ LandingFooter() {
<footer class="relative w-full bottom-0 bg-gray-900 pb-6">
<div class="container mx-auto px-4">
<hr class="mb-6 border-b-1 border-gray-700"/>
<div
class="flex flex-wrap items-center md:justify-between justify-center"
>
<div class="w-full md:w-4/12 px-4">
<div class="text-sm text-white font-semibold py-1">
Copyright © 2023
<a
href="https://git.preston-baxter.com"
class="text-white hover:text-gray-400 text-sm font-semibold py-1"
>Preston Baxter</a>
</div>
</div>
<div class="w-full md:w-8/12 px-4">
<ul
class="flex flex-wrap list-none md:justify-end justify-center"
>
<li>
<a
href="https://git.preston-baxter.com"
class="text-white hover:text-gray-400 text-sm font-semibold block py-1 px-3"
>Preston Baxter</a>
</li>
<li>
<a
href="/about-us"
class="text-white hover:text-gray-400 text-sm font-semibold block py-1 px-3"
>About Us</a>
</li>
</ul>
</div>
</div>
</div>
</footer>
}
templ DashboardFooter() {
<footer class="absolute w-full bottom-0 bg-gray-900 pb-6">
<div class="md:ml-64 md:mr-16 px-4">
<hr class="mb-6 border-b-1 border-gray-700"/>
<div
class="flex flex-wrap items-center md:justify-end justify-center"
>
<div class="w-full md:w-6/12 px-4">
<div class="text-sm text-white font-semibold py-1">
Copyright © 2023
<a
href="https://git.preston-baxter.com"
class="text-white hover:text-gray-400 text-sm font-semibold py-1"
>Preston Baxter</a>
</div>
</div>
<div class="w-full md:w-6/12 px-4">
<ul
class="flex flex-wrap list-none md:justify-end justify-center"
>
<li>
<a
href="https://git.preston-baxter.com"
class="text-white hover:text-gray-400 text-sm font-semibold block py-1 px-3"
>Preston Baxter</a>
</li>
<li>
<a
href="/about-us"
class="text-white hover:text-gray-400 text-sm font-semibold block py-1 px-3"
>About Us</a>
</li>
</ul>
</div>
</div>
</div>
</footer>
}
templ LoginFooter() {
<footer class="absolute w-full bottom-0 bg-gray-900 pb-6">
<div class="md:mx-16 px-4">
<hr class="mb-6 border-b-1 border-gray-700"/>
<div
class="flex flex-wrap items-center md:justify-end justify-center"
>
<div class="w-full md:w-6/12 px-4">
<div class="text-sm text-white font-semibold py-1">
Copyright © 2023
<a
href="https://git.preston-baxter.com"
class="text-white hover:text-gray-400 text-sm font-semibold py-1"
>Preston Baxter</a>
</div>
</div>
<div class="w-full md:w-6/12 px-4">
<ul
class="flex flex-wrap list-none md:justify-end justify-center"
>
<li>
<a
href="https://git.preston-baxter.com"
class="text-white hover:text-gray-400 text-sm font-semibold block py-1 px-3"
>Preston Baxter</a>
</li>
<li>
<a
href="/about-us"
class="text-white hover:text-gray-400 text-sm font-semibold block py-1 px-3"
>About Us</a>
</li>
</ul>
</div>
</div>
</div>
</footer>
}

View File

@ -0,0 +1,294 @@
package templates
templ LandingContent() {
<main>
<div class="relative pt-16 pb-32 flex content-center items-center justify-center" style="min-height: 75vh;">
<div
class="absolute top-0 w-full h-full bg-center bg-cover"
style="background-image: url(&#34;https://images.unsplash.com/photo-1522158637959-30385a09e0da?ixlib=rb-1.2.1&amp;ixid=eyJhcHBfaWQiOjEyMDd9&amp;auto=format&amp;fit=crop&amp;w=1267&amp;q=80&#34;);"
>
<span id="blackOverlay" class="w-full h-full absolute opacity-75 bg-black"></span>
</div>
<div class="container relative mx-auto">
<div class="items-center flex flex-wrap">
<div class="w-full lg:w-6/12 px-4 ml-auto mr-auto text-center">
<div class="pr-12">
<h1 class="text-white font-semibold text-5xl">
Automate the things that take time
</h1>
<p class="mt-4 text-lg text-gray-300">
Ministry has enough activities that take time. Automate
the things that take you away from the great commision.
Have more time to do what you are called to
</p>
</div>
</div>
</div>
</div>
<div
class="top-auto bottom-0 left-0 right-0 w-full absolute pointer-events-none overflow-hidden"
style="height: 70px;"
>
<svg
class="absolute bottom-0 overflow-hidden"
xmlns="http://www.w3.org/2000/svg"
preserveAspectRatio="none"
version="1.1"
viewBox="0 0 2560 100"
x="0"
y="0"
>
<polygon class="text-gray-300 fill-current" points="2560 0 2560 100 0 100"></polygon>
</svg>
</div>
</div>
<section class="pb-20 bg-gray-300 -mt-24">
<div class="container mx-auto px-4">
<div class="flex flex-wrap">
<div class="lg:pt-12 pt-6 w-full md:w-4/12 px-4 text-center">
<div
class="relative flex flex-col min-w-0 break-words bg-white w-full mb-8 shadow-lg rounded-lg"
>
<div class="px-4 py-5 flex-auto">
<div
class="text-white p-3 text-center inline-flex items-center justify-center w-12 h-12 mb-5 shadow-lg rounded-full bg-red-400"
>
<i class="fa-brands fa-youtube"></i>
</div>
<h6 class="text-xl font-semibold">Youtube Live streams</h6>
<p class="mt-2 mb-4 text-gray-600">
Automatically schedule youtube live streams. Never manage your broadcasts again
with automated scheduling
</p>
</div>
</div>
</div>
<div class="w-full md:w-4/12 px-4 text-center">
<div
class="relative flex flex-col min-w-0 break-words bg-white w-full mb-8 shadow-lg rounded-lg"
>
<div class="px-4 py-5 flex-auto">
<div
class="text-white p-3 text-center inline-flex items-center justify-center w-12 h-12 mb-5 shadow-lg rounded-full bg-blue-400"
>
<i class="fas fa-retweet"></i>
</div>
<h6 class="text-xl font-semibold">Connect it all</h6>
<p class="mt-2 mb-4 text-gray-600">
Connect the services that take time out of your day.
Let us do the hard work of scheduling and managing them
</p>
</div>
</div>
</div>
<div class="pt-6 w-full md:w-4/12 px-4 text-center">
<div
class="relative flex flex-col min-w-0 break-words bg-white w-full mb-8 shadow-lg rounded-lg"
>
<div class="px-4 py-5 flex-auto">
<div
class="text-white p-0 text-center inline-flex items-center justify-center w-12 h-12 mb-5 shadow-lg rounded-full bg-transparent"
>
<img class="" src="/static/app-icon-services-400.png"></img>
</div>
<h6 class="text-xl font-semibold">Planning Center</h6>
<p class="mt-2 mb-4 text-gray-600">
Link your planning center to automatically trigger events.
Schedule live streams, and soon social media posts as well.
</p>
</div>
</div>
</div>
</div>
<div class="flex flex-wrap items-center mt-32">
<div class="w-full md:w-5/12 px-4 mr-auto ml-auto">
<div
class="text-gray-600 p-3 text-center inline-flex items-center justify-center w-16 h-16 mb-6 shadow-lg rounded-full bg-gray-100"
>
<i class="fas fa-church text-xl"></i>
</div>
<h3 class="text-3xl mb-2 font-semibold leading-normal">
Built by church people for church people
</h3>
<p class="text-lg font-light leading-relaxed mt-4 mb-4 text-gray-700">
With over half a decade of time spen as the technical director of
my local church I know the things that take time away from the
important things.
</p>
<p class="text-lg font-light leading-relaxed mt-0 mb-4 text-gray-700">
I have built this to take the hassle out of last minute Planning center
changes. No need to update your livestream, facebook posts, and instagram
stories. We take care of that for you
</p>
<a
href="https://www.creative-tim.com/learning-lab/tailwind-starter-kit#/presentation"
class="font-bold text-gray-800 mt-8"
>Check Ministry Auto Tools</a>
</div>
<div class="w-full md:w-4/12 px-4 mr-auto ml-auto">
<div
class="relative flex flex-col min-w-0 break-words bg-white w-full mb-6 shadow-lg rounded-lg bg-blue-600"
>
<img
alt="..."
src="https://images.unsplash.com/photo-1522202176988-66273c2fd55f?ixlib=rb-1.2.1&amp;ixid=eyJhcHBfaWQiOjEyMDd9&amp;auto=format&amp;fit=crop&amp;w=1051&amp;q=80"
class="w-full align-middle rounded-t-lg"
/>
<blockquote class="relative p-8 mb-4">
<svg
preserveAspectRatio="none"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 583 95"
class="absolute left-0 w-full block"
style="height: 95px; top: -94px;"
>
<polygon points="-30,95 583,95 583,65" class="text-blue-600 fill-current"></polygon>
</svg>
<h4 class="text-xl font-bold text-white">
The best people for your people
</h4>
<p class="text-md font-light mt-2 text-white">
We are here to make sure your people can do what they do best.
Be with your people. Stop spending time working on the things
that take you away from that.
</p>
</blockquote>
</div>
</div>
</div>
</div>
</section>
<section class="pb-20 relative block bg-gray-900">
<div
class="bottom-auto top-0 left-0 right-0 w-full absolute pointer-events-none overflow-hidden -mt-20"
style="height: 80px;"
>
<svg
class="absolute bottom-0 overflow-hidden"
xmlns="http://www.w3.org/2000/svg"
preserveAspectRatio="none"
version="1.1"
viewBox="0 0 2560 100"
x="0"
y="0"
>
<polygon class="text-gray-900 fill-current" points="2560 0 2560 100 0 100"></polygon>
</svg>
</div>
<div class="container mx-auto px-4 lg:pt-24 lg:pb-64">
<div class="flex flex-wrap text-center justify-center">
<div class="w-full lg:w-6/12 px-4">
<h2 class="text-4xl font-semibold text-white">Take something off your plate</h2>
<p class="text-lg leading-relaxed mt-4 mb-4 text-gray-500">
There is so much room to take chores of ministry off the plate
of volunteers and staff members alike. Let us do that for you.
Together we can spend more time with people
</p>
</div>
</div>
<div class="flex flex-wrap mt-12 justify-center">
<div class="w-full lg:w-3/12 px-4 text-center">
<div
class="text-gray-900 p-3 w-12 h-12 shadow-lg rounded-full bg-white inline-flex items-center justify-center"
>
<i class="fas fa-medal text-xl"></i>
</div>
<h6 class="text-xl mt-5 font-semibold text-white">
Excelent Services
</h6>
<p class="mt-2 mb-4 text-gray-500">
With high quality integrations with the services you use every day,
we will become your one stop shop
</p>
</div>
<div class="w-full lg:w-3/12 px-4 text-center">
<div
class="text-gray-900 p-3 w-12 h-12 shadow-lg rounded-full bg-white inline-flex items-center justify-center"
>
<i class="fas fa-poll text-xl"></i>
</div>
<h5 class="text-xl mt-5 font-semibold text-white">
Track the action
</h5>
<p class="mt-2 mb-4 text-gray-500">
With audit and reporting tools, track how an event moves through the system.
Make sure what you want is what you get
</p>
</div>
<div class="w-full lg:w-3/12 px-4 text-center">
<div
class="text-gray-900 p-3 w-12 h-12 shadow-lg rounded-full bg-white inline-flex items-center justify-center"
>
<i class="fas fa-lightbulb text-xl"></i>
</div>
<h5 class="text-xl mt-5 font-semibold text-white">Get Creative</h5>
<p class="mt-2 mb-4 text-gray-500">
If you want simple we have it. If you want complicated we have it.
Build what you wnat how you want
</p>
</div>
</div>
</div>
</section>
<section class="relative block py-24 lg:pt-0 bg-gray-900">
<div class="container mx-auto px-4">
<div class="flex flex-wrap justify-center lg:-mt-64 -mt-48">
<div class="w-full lg:w-6/12 px-4">
<div
class="relative flex flex-col min-w-0 break-words w-full mb-6 shadow-lg rounded-lg bg-gray-300"
>
<div class="flex-auto p-5 lg:p-10">
<h4 class="text-2xl font-semibold">Want to try it out?</h4>
<p class="leading-relaxed mt-1 mb-4 text-gray-600">
Complete this form and we will get back to you in 24 hours.
</p>
<div class="relative w-full mb-3 mt-8">
<label
class="block uppercase text-gray-700 text-xs font-bold mb-2"
for="full-name"
>Full Name</label><input
type="text"
class="border-0 px-3 py-3 placeholder-gray-400 text-gray-700 bg-white rounded text-sm shadow focus:outline-none focus:ring w-full"
placeholder="Full Name"
style="transition: all 0.15s ease 0s;"
/>
</div>
<div class="relative w-full mb-3">
<label
class="block uppercase text-gray-700 text-xs font-bold mb-2"
for="email"
>Email</label><input
type="email"
class="border-0 px-3 py-3 placeholder-gray-400 text-gray-700 bg-white rounded text-sm shadow focus:outline-none focus:ring w-full"
placeholder="Email"
style="transition: all 0.15s ease 0s;"
/>
</div>
<div class="relative w-full mb-3">
<label
class="block uppercase text-gray-700 text-xs font-bold mb-2"
for="message"
>Message</label><textarea
rows="4"
cols="80"
class="border-0 px-3 py-3 placeholder-gray-400 text-gray-700 bg-white rounded text-sm shadow focus:outline-none focus:ring w-full"
placeholder="Type a message..."
></textarea>
</div>
<div class="text-center mt-6">
<button
class="bg-gray-900 text-white active:bg-gray-700 text-sm font-bold uppercase px-6 py-3 rounded shadow hover:shadow-lg outline-none focus:outline-none mr-1 mb-1"
type="button"
style="transition: all 0.15s ease 0s;"
>
Send Message
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
</main>
}

View File

@ -0,0 +1,48 @@
package templates
import "git.preston-baxter.com/Preston_PLB/capstone/frontend-service/db/models"
//Head for scripts and such
templ Head(msg string) {
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<meta name="theme-color" content="#000000"/>
<meta name="view-transition" content="same-origin"/>
<meta name="htmx-config" content='{"globalViewTransistions": true}'/>
<link rel="shortcut icon" href="/static/favicon.ico"/>
<link
rel="apple-touch-icon"
sizes="76x76"
href="/static/apple-icon.png"
/>
<link
rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.2/css/all.min.css"
/>
<link
rel="stylesheet"
href="/static/output.css"
/>
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/gh/creativetimofficial/tailwind-starter-kit/compiled-tailwind.min.css"
/>
<title>{ msg } | Capstone - Pbaxt10</title>
<script src="https://unpkg.com/htmx.org@1.9.6" integrity="sha384-FhXw7b6AlE/jyjlZH5iHa/tTe9EpJ1Y55RjcgPbjeWMskSxZt1v9qkxLJWNJaGni" crossorigin="anonymous"></script>
</head>
}
templ LandingPage(user *models.User) {
<!DOCTYPE html>
<html>
@Head("Welcome")
<body class="text-gray-800 antialiased">
@Nav(user)
@LandingContent()
</body>
@LandingFooter()
@toggleNavBar()
</html>
}

View File

@ -0,0 +1,110 @@
package templates
templ LoginContent(signup bool, errorMsg string) {
<main>
<section class="absolute w-full h-full">
<div
class="absolute top-0 w-full h-full bg-gray-900"
style="background-size: 100%; background-repeat: no-repeat;"
></div>
<div class="container mx-auto px-4 h-full">
<div class="flex content-center items-center justify-center h-full">
<div class="w-full lg:w-4/12 px-4">
<div
class="relative flex flex-col min-w-0 break-words w-full mb-6 shadow-lg rounded-lg bg-gray-300 border-0"
>
<div class="flex-auto px-4 lg:px-10 py-10 pt-10">
if errorMsg != "" {
<div role="alert">
<div class="bg-red-500 text-white font-bold rounded-t px-4 py-2">
Error
</div>
<div class="border border-t-0 border-red-400 rounded-b bg-red-100 px-4 py-3 text-red-700">
<p>{ errorMsg }</p>
</div>
</div>
}
<div class="text-gray-500 text-center mb-3 font-bold">
<small>Sign in</small>
</div>
<form
if signup {
action="/signup"
} else {
action="/login"
}
method="POST"
>
<div class="relative w-full mb-3">
<label
class="block uppercase text-gray-700 text-xs font-bold mb-2"
for="grid-password"
>Email</label><input
type="email"
class="border-0 px-3 py-3 placeholder-gray-400 text-gray-700 bg-white rounded text-sm shadow focus:outline-none focus:ring w-full"
placeholder="Email"
style="transition: all 0.15s ease 0s;"
name="email"
/>
</div>
<div class="relative w-full mb-3">
<label
class="block uppercase text-gray-700 text-xs font-bold mb-2"
for="grid-password"
>Password</label><input
type="password"
class="border-0 px-3 py-3 placeholder-gray-400 text-gray-700 bg-white rounded text-sm shadow focus:outline-none focus:ring w-full"
placeholder="Password"
style="transition: all 0.15s ease 0s;"
name="password"
/>
</div>
<div>
<label class="inline-flex items-center cursor-pointer">
<input
id="customCheckLogin"
type="checkbox"
class="form-checkbox border-0 rounded text-gray-800 ml-1 w-5 h-5"
style="transition: all 0.15s ease 0s;"
/><span class="ml-2 text-sm font-semibold text-gray-700">Remember me</span>
</label>
</div>
<div class="text-center mt-6">
<button
type="submit"
class="bg-gray-900 text-white active:bg-gray-700 text-sm font-bold uppercase px-6 py-3 rounded shadow hover:shadow-lg outline-none focus:outline-none mr-1 mb-1 w-full"
type="button"
style="transition: all 0.15s ease 0s;"
>
if signup {
{ "Signup" }
} else {
{ "Login" }
}
</button>
</div>
</form>
</div>
<div class="flex flex-wrap mt-2 py-5">
if !signup {
<div class="w-1/2 text-center">
<a href="#pablo" class="text-gray-800"><small>Forgot password?</small></a>
</div>
}
if signup {
<div class="w-full text-center">
<a href="/login" class="text-gray-800"><small>Log in instead</small></a>
</div>
} else {
<div class="w-1/2 text-center">
<a href="/signup" class="text-gray-800"><small>Create an account</small></a>
</div>
}
</div>
</div>
</div>
</div>
</div>
</section>
</main>
}

View File

@ -0,0 +1,27 @@
package templates
templ LoginPage(errorMsg string) {
<!DOCTYPE html>
<html>
@Head("Log in")
<body class="text-gray-800 antialiased h-full overflow-hidden">
@Nav(nil)
@LoginContent(false, errorMsg)
</body>
@LoginFooter()
@toggleNavBar()
</html>
}
templ SignupPage(errorMsg string) {
<!DOCTYPE html>
<html>
@Head("Sign up")
<body class="text-gray-800 antialiased h-full overflow-hidden">
@Nav(nil)
@LoginContent(true, errorMsg)
</body>
@LoginFooter()
@toggleNavBar()
</html>
}

84
ui/templates/nav.templ Normal file
View File

@ -0,0 +1,84 @@
package templates
import (
"git.preston-baxter.com/Preston_PLB/capstone/frontend-service/db/models"
)
templ navTitle(title string) {
<a
class="text-sm font-bold leading-relaxed inline-block mr-4 py-2 whitespace-nowrap uppercase text-white"
href="/"
>
{ title }
</a>
<button
class="cursor-pointer text-xl leading-none px-3 py-1 border border-solid border-transparent rounded bg-transparent block lg:hidden outline-none focus:outline-none"
type="button"
onclick="toggleNavbar(&#39;example-collapse-navbar&#39;)"
>
<i class="text-white fas fa-bars"></i>
</button>
}
templ navItem(name string, link string) {
<li class="flex items-center">
<a
class="lg:text-white lg:hover:text-gray-300 text-gray-800 px-3 py-4 lg:py-2 flex items-center text-xs uppercase font-bold"
href="{ link }"
>
{ name }
</a>
</li>
}
templ navActionItem(auth bool) {
<li class="flex items-center">
if auth {
<a href="/dashboard">
<button
class="bg-white text-gray-800 active:bg-gray-100 text-xs font-bold uppercase px-4 py-2 rounded shadow hover:shadow-md outline-none focus:outline-none lg:mr-1 lg:mb-0 ml-3 mb-3"
type="button"
style="transition: all 0.15s ease 0s;"
>
Dashboard
</button>
</a>
} else {
<a href="/login">
<button
class="bg-white text-gray-800 active:bg-gray-100 text-xs font-bold uppercase px-4 py-2 rounded shadow hover:shadow-md outline-none focus:outline-none lg:mr-1 lg:mb-0 ml-3 mb-3"
type="button"
style="transition: all 0.15s ease 0s;"
>
Log in
</button>
</a>
}
</li>
}
templ Nav(user *models.User) {
<nav class="top-0 absolute z-50 w-full flex flex-wrap items-center justify-between px-2 py-3 ">
<div class="container px-4 mx-auto flex flex-wrap items-center justify-between">
<div class="w-full relative flex justify-between lg:w-auto lg:static lg:block lg:justify-start">
@navTitle("Capstone")
</div>
<div
class="lg:flex flex-grow items-center bg-white lg:bg-transparent lg:shadow-none hidden"
id="example-collapse-navbar"
>
<ul class="flex flex-col lg:flex-row list-none mr-auto">
@navItem("About Us", "/about-us")
@navItem("Pricing", "/pricing")
</ul>
<ul class="flex flex-col lg:flex-row list-none lg:ml-auto">
if user != nil {
@navActionItem(true)
} else {
@navActionItem(false)
}
</ul>
</div>
</div>
</nav>
}

Some files were not shown because too many files have changed in this diff Show More