commit 9d9cdbaebc99f6ea2aa2e33d7ecf72afa317ac4d Author: noahspannbauer Date: Fri Jul 17 12:28:28 2026 -0500 First commit diff --git a/.gitea/workflows/build_and_test.yaml b/.gitea/workflows/build_and_test.yaml new file mode 100644 index 0000000..a492871 --- /dev/null +++ b/.gitea/workflows/build_and_test.yaml @@ -0,0 +1,82 @@ +name: 'API Build' +on: + workflow_call: + inputs: + environment_name: + required: true + type: string + version_number: + required: true + type: string + +jobs: + build-and-test: + runs-on: ubuntu-latest + environment: ${{ inputs.environment_name }} + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + sparse-checkout: | + api + client + + - name: Get Secrets from Infisical + uses: Infisical/secrets-action@v1.0.16 + with: + domain: ${{ secrets.INFISICAL_DOMAIN }} + client-id: ${{ secrets.INFISICAL_CLIENT_ID }} + client-secret: ${{ secrets.INFISICAL_CLIENT_SECRET }} + project-slug: ${{ secrets.INFISICAL_PROJECT_SLUG }} + env-slug: ${{ inputs.environment_name }} + secret-path: /portfolio + + - name: Install Nest CLI + run: | + npm install -g @nestjs/cli + + - name: 'Setup Node' + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + + - name: Install dependencies + run: | + npm ci + + - name: Build API + run: | + npm run build -w api + + - name: Test API + run: | + npm run test -w api + + - name: Build Client + env: + VITE_API_URL: ${{ env.VITE_API_URL }} + VITE_BASE_URL: ${{ env.VITE_BASE_URL }} + VITE_CLIENT_ID: ${{ env.VITE_CLIENT_ID }} + VITE_ISSUER_URI: ${{ env.VITE_ISSUER_URI }} + VITE_TENANT_ID: ${{ env.VITE_TENANT_ID }} + run: | + npm run build -w client + + - name: Log into Docker Hub + if: ${{ github.event_name != 'pull_request' }} + uses: docker/login-action@v3 + with: + username: ${{ env.DOCKERHUB_USERNAME }} + password: ${{ env.DOCKERHUB_TOKEN }} + + - name: Setup Docker Buildx + if: ${{ github.event_name != 'pull_request' }} + uses: docker/setup-buildx-action@v3 + + - name: Build and push to registry + if: ${{ github.event_name != 'pull_request' }} + uses: docker/build-push-action@v6 + with: + push: true + tags: noahspan/flying:${{ inputs.version_number }} + context: . diff --git a/.gitea/workflows/changes.yaml b/.gitea/workflows/changes.yaml new file mode 100644 index 0000000..5809052 --- /dev/null +++ b/.gitea/workflows/changes.yaml @@ -0,0 +1,38 @@ +name: Changes +on: + workflow_call: + outputs: + api: + value: ${{ jobs.changes.outputs.api }} + client: + value: ${{ jobs.changes.outputs.client }} + +permissions: + contents: read + +jobs: + changes: + name: filter + runs-on: ubuntu-latest + outputs: + api: ${{ steps.filter.outputs.api }} + client: ${{ steps.filter.outputs.client }} + steps: + - name: 'Setup Node' + uses: actions/setup-node@v6 + with: + node-version: '22.x' + + - name: Checkout + uses: actions/checkout@v4 + + - uses: dorny/paths-filter@v3 + id: filter + with: + filters: | + api: + - 'api/**' + client: + - 'client/**' + infrastructure: + - 'infrastructure/**' \ No newline at end of file diff --git a/.gitea/workflows/deploy.yaml b/.gitea/workflows/deploy.yaml new file mode 100644 index 0000000..48126a2 --- /dev/null +++ b/.gitea/workflows/deploy.yaml @@ -0,0 +1,44 @@ +name: Deploy +on: + workflow_call: + inputs: + app_name: + required: true + type: string + environment_name: + required: true + type: string + version_number: + required: true + type: string + +permissions: + id-token: write + contents: read + +jobs: + deploy: + runs-on: ubuntu-latest + environment: ${{ inputs.environment_name }} + steps: + - name: Get Secrets from Infisical + uses: Infisical/secrets-action@v1.0.16 + with: + domain: ${{ secrets.INFISICAL_DOMAIN }} + client-id: ${{ secrets.INFISICAL_CLIENT_ID }} + client-secret: ${{ secrets.INFISICAL_CLIENT_SECRET }} + project-slug: ${{ secrets.INFISICAL_PROJECT_SLUG }} + env-slug: ${{ inputs.environment_name }} + secret-path: /portfolio + + - name: Install Azure CLI + run: | + curl -sL https://aka.ms/InstallAzureCLIDeb | sudo bash + + - name: Log in to Azure + run: | + az login --service-principal --username ${{ env.AZURE_CLIENT_ID }} --password ${{ env.AZURE_CLIENT_SECRET }} --tenant ${{ env.AZURE_TENANT_ID }} + + - name: Update Container App + run: | + az containerapp update --name ${{ inputs.app_name }}-${{ inputs.environment_name }} --container-name ${{ inputs.app_name }} --resource-group ${{ env.RESOURCE_GROUP }} --image docker.io/noahspan/${{ inputs.app_name }}:${{ inputs.version_number }} \ No newline at end of file diff --git a/.gitea/workflows/main.yaml b/.gitea/workflows/main.yaml new file mode 100644 index 0000000..7523f32 --- /dev/null +++ b/.gitea/workflows/main.yaml @@ -0,0 +1,32 @@ +name: Main +on: + push: + branches: + - main + +jobs: + changes: + uses: ./.gitea/workflows/changes.yaml + + build-and-test: + if: ${{ needs.changes.outputs.api == 'true' || needs.changes.outputs.client == 'true' }} + name: build-and-test + needs: + - changes + uses: ./.gitea/workflows/build_and_test.yaml + with: + environment_name: test + version_number: ${{ github.run_id }} + secrets: inherit + + deploy: + name: deploy + needs: + - changes + - build-and-test + uses: ./.gitea/workflows/deploy.yaml + with: + app_name: flying + environment_name: test + version_number: ${{ github.run_id }} + secrets: inherit diff --git a/.gitea/workflows/pull_request.yaml b/.gitea/workflows/pull_request.yaml new file mode 100644 index 0000000..2bed9a6 --- /dev/null +++ b/.gitea/workflows/pull_request.yaml @@ -0,0 +1,19 @@ +name: Pull Request + +on: + pull_request: + +jobs: + changes: + uses: ./.gitea/workflows/changes.yaml + + build: + if: ${{ needs.changes.outputs.api == 'true' || needs.changes.outputs.client == 'true'}} + name: build-and-test + needs: + - changes + uses: ./.gitea/workflows/build_and_test.yaml + with: + environment_name: test + version_number: ${{ github.run_id }} + secrets: inherit diff --git a/.gitea/workflows/tag.yaml b/.gitea/workflows/tag.yaml new file mode 100644 index 0000000..aac6e22 --- /dev/null +++ b/.gitea/workflows/tag.yaml @@ -0,0 +1,25 @@ +name: Tag +on: + push: + tags: + - '**' + +jobs: + build-and-test: + name: build-and-test + uses: ./.gitea/workflows/build_and_test.yaml + with: + environment_name: prod + version_number: ${{ github.ref_name }} + secrets: inherit + + deploy: + name: deploy + needs: + - build-and-test + uses: ./.gitea/workflows/deploy.yaml + with: + app_name: flying + environment_name: prod + version_number: ${{ github.ref_name }} + secrets: inherit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ac4b1b3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.DS_Store +*.auto.tfvars \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 diff --git a/api/.eslintrc.js b/api/.eslintrc.js new file mode 100644 index 0000000..259de13 --- /dev/null +++ b/api/.eslintrc.js @@ -0,0 +1,25 @@ +module.exports = { + parser: '@typescript-eslint/parser', + parserOptions: { + project: 'tsconfig.json', + tsconfigRootDir: __dirname, + sourceType: 'module', + }, + plugins: ['@typescript-eslint/eslint-plugin'], + extends: [ + 'plugin:@typescript-eslint/recommended', + 'plugin:prettier/recommended', + ], + root: true, + env: { + node: true, + jest: true, + }, + ignorePatterns: ['.eslintrc.js'], + rules: { + '@typescript-eslint/interface-name-prefix': 'off', + '@typescript-eslint/explicit-function-return-type': 'off', + '@typescript-eslint/explicit-module-boundary-types': 'off', + '@typescript-eslint/no-explicit-any': 'off', + }, +}; diff --git a/api/.gitignore b/api/.gitignore new file mode 100644 index 0000000..ba2c55b --- /dev/null +++ b/api/.gitignore @@ -0,0 +1,58 @@ +# compiled output +/dist +/node_modules +/build + +# Logs +logs +*.log +npm-debug.log* +pnpm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* + +# OS +.DS_Store + +# Tests +/coverage +/.nyc_output + +# IDEs and editors +/.idea +.project +.classpath +.c9/ +*.launch +.settings/ +*.sublime-workspace + +# IDE - VSCode +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# temp directory +.temp +.tmp + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +local.settings.json diff --git a/api/.prettierrc b/api/.prettierrc new file mode 100644 index 0000000..dcb7279 --- /dev/null +++ b/api/.prettierrc @@ -0,0 +1,4 @@ +{ + "singleQuote": true, + "trailingComma": "all" +} \ No newline at end of file diff --git a/api/README.md b/api/README.md new file mode 100644 index 0000000..e69de29 diff --git a/api/nest-cli.json b/api/nest-cli.json new file mode 100644 index 0000000..f9aa683 --- /dev/null +++ b/api/nest-cli.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src", + "compilerOptions": { + "deleteOutDir": true + } +} diff --git a/api/package.json b/api/package.json new file mode 100644 index 0000000..ef5d5cd --- /dev/null +++ b/api/package.json @@ -0,0 +1,87 @@ +{ + "name": "api", + "version": "1.2.0", + "description": "", + "author": "", + "private": true, + "license": "UNLICENSED", + "scripts": { + "build": "nest build", + "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", + "start": "nest start", + "start:dev": "nest start --watch", + "start:debug": "nest start --debug --watch", + "start:prod": "node dist/main", + "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", + "test": "jest", + "test:watch": "jest --watch", + "test:cov": "jest --coverage", + "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", + "test:e2e": "jest --config ./test/jest-e2e.json", + "typeorm": "npm run build && npx typeorm -d dist/database/data-source.js", + "migration:generate": "npm run typeorm -- migration:generate", + "migration:run": "npm run typeorm -- migration:run", + "migration:revert": "npm run typeorm -- migration:revert", + "migration:show": "npm run typeorm -- migration:show", + "seed": "npm run build && typeorm-extension seed:run -d ./database/data-source.ts" + }, + "dependencies": { + "@nestjs/axios": "^4.0.0", + "@nestjs/common": "^11.0.11", + "@nestjs/config": "^4.0.1", + "@nestjs/core": "^11.0.11", + "@nestjs/platform-express": "^11.0.11", + "@nestjs/typeorm": "^11.0.1", + "@noahspan/noahspan-modules": "^1.2.11", + "axios": "^1.7.9", + "better-sqlite3": "^12.2.0", + "reflect-metadata": "^0.2.2", + "rxjs": "7.8.2", + "uuid": "^11.1.1" + }, + "devDependencies": { + "@angular-devkit/schematics": "^19.1.4", + "@nestjs/cli": "^11.0.5", + "@nestjs/schematics": "^11.0.2", + "@nestjs/testing": "^11.0.11", + "@schematics/angular": "^19.1.4", + "@types/express": "^4.17.17", + "@types/jest": "^29.5.2", + "@types/node": "^20.3.1", + "@types/supertest": "^6.0.0", + "@typescript-eslint/eslint-plugin": "^6.0.0", + "@typescript-eslint/parser": "^6.0.0", + "eslint": "^8.42.0", + "eslint-config-prettier": "^9.0.0", + "eslint-plugin-prettier": "^5.0.0", + "jest": "^29.5.0", + "prettier": "^3.0.0", + "source-map-support": "^0.5.21", + "supertest": "^6.3.3", + "ts-jest": "^29.1.0", + "ts-loader": "^9.4.3", + "ts-node": "^10.9.1", + "tsconfig-paths": "^4.2.0", + "typescript": "^5.1.3" + }, + "files": [ + "dist" + ], + "jest": { + "moduleFileExtensions": [ + "js", + "json", + "ts" + ], + "rootDir": "src", + "testRegex": ".*\\.spec\\.ts$", + "transform": { + "^.+\\.(t|j)s$": "ts-jest" + }, + "collectCoverageFrom": [ + "**/*.(t|j)s" + ], + "coverageDirectory": "../coverage", + "testEnvironment": "node" + } +} diff --git a/api/src/app.controller.spec.ts b/api/src/app.controller.spec.ts new file mode 100644 index 0000000..d22f389 --- /dev/null +++ b/api/src/app.controller.spec.ts @@ -0,0 +1,22 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { AppController } from './app.controller'; +import { AppService } from './app.service'; + +describe('AppController', () => { + let appController: AppController; + + beforeEach(async () => { + const app: TestingModule = await Test.createTestingModule({ + controllers: [AppController], + providers: [AppService], + }).compile(); + + appController = app.get(AppController); + }); + + describe('root', () => { + it('should return "Hello World!"', () => { + expect(appController.getHello()).toBe('Hello World!'); + }); + }); +}); diff --git a/api/src/app.module.ts b/api/src/app.module.ts new file mode 100644 index 0000000..6e14635 --- /dev/null +++ b/api/src/app.module.ts @@ -0,0 +1,56 @@ +import { Module } from '@nestjs/common'; +import { APP_FILTER } from '@nestjs/core'; +import { HttpExceptionFilter } from './filters/http-exception.filter'; +import { ProjectModule } from './project/project.module'; +import { AuthModule, MsGraphModule } from '@noahspan/noahspan-modules' +import { ConfigModule, ConfigService } from '@nestjs/config'; +import configuration from './config/configuration'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { dataSourceOptions } from './database/data-source'; +import { SkillModule } from './skill/skill.module'; +import { ProfileModule } from './profile/profile.module'; +import { ExperienceModule } from './experience/experience.module'; + +@Module({ + imports: [ + AuthModule.registerAsync({ + inject: [ConfigService], + imports: [ConfigModule], + useFactory: async (configService: ConfigService) => { + return { + audience: configService.get('audience'), + issuerUrl: configService.get('issuer'), + jwksUri: configService.get('jwksUri') + } + } + }), + ConfigModule.forRoot({ + isGlobal: true, + load: [configuration] + }), + ExperienceModule, + MsGraphModule.registerAsync({ + inject: [ConfigService], + imports: [ConfigModule], + useFactory: async (configService: ConfigService) => { + return { + authority: configService.get('authority'), + clientId: configService.get('clientId'), + clientSecret: configService.get('clientSecret'), + tenantId: configService.get('tenantId') + } + } + }), + ProfileModule, + ProjectModule, + SkillModule, + TypeOrmModule.forRoot(dataSourceOptions), + ], + providers: [ + { + provide: APP_FILTER, + useClass: HttpExceptionFilter + } + ] +}) +export class AppModule {} diff --git a/api/src/config/configuration.ts b/api/src/config/configuration.ts new file mode 100644 index 0000000..b53cc89 --- /dev/null +++ b/api/src/config/configuration.ts @@ -0,0 +1,10 @@ +export default () => ({ + azureStorageConnectionString: process.env.AZURE_STORAGE_CONNECTION_STRING, + audience: process.env.AUDIENCE, + authority: process.env.AUTHORITY, + clientId: process.env.CLIENT_ID, + clientSecret: process.env.CLIENT_SECRET, + issuer: process.env.ISSUER_URL, + jwksUri: process.env.JWKS_URI, + tenantId: process.env.TENANT_ID +}) \ No newline at end of file diff --git a/api/src/database/data-source.ts b/api/src/database/data-source.ts new file mode 100644 index 0000000..e401963 --- /dev/null +++ b/api/src/database/data-source.ts @@ -0,0 +1,22 @@ +import { DataSource, DataSourceOptions } from 'typeorm'; +import { config } from 'dotenv'; +import { ConfigService } from '@nestjs/config'; +import { SeederOptions } from 'typeorm-extension'; + +config(); + +const configService: ConfigService = new ConfigService(); + +export const dataSourceOptions: DataSourceOptions & SeederOptions = { + type: 'better-sqlite3', + database: configService.get('DB_PATH'), + entities: ['../**/*.entity.js'], + migrations: ['dist/database/migrations/**/*.js'], + migrationsRun: false, + seeds: ['dist/database/seeds/**/*.ts'], + synchronize: configService.get('DB_SYNC') +} + +const dataSource: DataSource = new DataSource(dataSourceOptions); + +export default dataSource; \ No newline at end of file diff --git a/api/src/database/migrations/1782738482901-initial_migration.ts b/api/src/database/migrations/1782738482901-initial_migration.ts new file mode 100644 index 0000000..901c874 --- /dev/null +++ b/api/src/database/migrations/1782738482901-initial_migration.ts @@ -0,0 +1,60 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class InitialMigration1782738482901 implements MigrationInterface { + name = 'InitialMigration1782738482901' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE "statuses" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "name" varchar NOT NULL)`); + await queryRunner.query(`CREATE TABLE "skill_categories" ("id" varchar PRIMARY KEY NOT NULL, "name" varchar NOT NULL, "created" datetime NOT NULL DEFAULT (datetime('now')), "createdBy" varchar, "updated" datetime NOT NULL DEFAULT (datetime('now')), "updatedBy" varchar, "deleted" datetime, "deletedBy" varchar)`); + await queryRunner.query(`CREATE TABLE "skill_levels" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "name" varchar NOT NULL, "created" datetime NOT NULL DEFAULT (datetime('now')), "createdBy" varchar, "updated" datetime NOT NULL DEFAULT (datetime('now')), "updatedBy" varchar, "deleted" datetime, "deletedBy" varchar)`); + await queryRunner.query(`CREATE TABLE "skill_ranks" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "name" varchar NOT NULL, "weight" integer NOT NULL, "created" datetime NOT NULL DEFAULT (datetime('now')), "createdBy" varchar, "updated" datetime NOT NULL DEFAULT (datetime('now')), "updatedBy" varchar, "deleted" datetime, "deletedBy" varchar)`); + await queryRunner.query(`CREATE TABLE "skills" ("id" varchar PRIMARY KEY NOT NULL, "name" varchar NOT NULL, "categoryId" varchar NOT NULL, "levelId" integer NOT NULL, "rankId" integer NOT NULL, "created" datetime NOT NULL DEFAULT (datetime('now')), "createdBy" varchar, "updated" datetime NOT NULL DEFAULT (datetime('now')), "updatedBy" varchar, "deleted" datetime, "deletedBy" varchar, CONSTRAINT "REL_168133e3158020b53392549d3e" UNIQUE ("rankId"))`); + await queryRunner.query(`CREATE TABLE "projects" ("id" varchar PRIMARY KEY NOT NULL, "name" varchar NOT NULL, "iconName" varchar NOT NULL, "order" integer NOT NULL, "summary" varchar NOT NULL, "repoUrl" varchar, "siteUrl" varchar, "statusId" integer NOT NULL, "created" datetime NOT NULL DEFAULT (datetime('now')), "createdBy" varchar, "updated" datetime NOT NULL DEFAULT (datetime('now')), "updatedBy" varchar, "deleted" datetime, "deletedBy" varchar, CONSTRAINT "REL_5a833dfa646b99b852c67dd859" UNIQUE ("statusId"))`); + await queryRunner.query(`CREATE TABLE "experiences" ("id" varchar PRIMARY KEY NOT NULL, "profileId" varchar NOT NULL, "companyName" varchar NOT NULL, "companyGeneric" varchar NOT NULL, "title" varchar NOT NULL, "startDate" datetime NOT NULL, "endDate" datetime, "summary" varchar, "statusId" integer NOT NULL, "created" datetime NOT NULL DEFAULT (datetime('now')), "createdBy" varchar, "updated" datetime NOT NULL DEFAULT (datetime('now')), "updatedBy" varchar, "deleted" datetime, "deletedBy" varchar, CONSTRAINT "REL_23dfcd8f2d1b6585b848f840d5" UNIQUE ("statusId"))`); + await queryRunner.query(`CREATE TABLE "profiles" ("id" varchar PRIMARY KEY NOT NULL, "name" varchar NOT NULL, "headline" varchar NOT NULL, "summary" varchar NOT NULL, "statusId" integer NOT NULL, "userId" varchar NOT NULL, "created" datetime NOT NULL DEFAULT (datetime('now')), "createdBy" varchar, "updated" datetime NOT NULL DEFAULT (datetime('now')), "updatedBy" varchar, "deleted" datetime, "deletedBy" varchar, CONSTRAINT "REL_a0e37cd676d13d116f73d494ee" UNIQUE ("statusId"))`); + await queryRunner.query(`CREATE TABLE "temporary_skills" ("id" varchar PRIMARY KEY NOT NULL, "name" varchar NOT NULL, "categoryId" varchar NOT NULL, "levelId" integer NOT NULL, "rankId" integer NOT NULL, "created" datetime NOT NULL DEFAULT (datetime('now')), "createdBy" varchar, "updated" datetime NOT NULL DEFAULT (datetime('now')), "updatedBy" varchar, "deleted" datetime, "deletedBy" varchar, CONSTRAINT "REL_168133e3158020b53392549d3e" UNIQUE ("rankId"), CONSTRAINT "FK_06d267f85858229c10a01a08ad7" FOREIGN KEY ("categoryId") REFERENCES "skill_categories" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION, CONSTRAINT "FK_168133e3158020b53392549d3ee" FOREIGN KEY ("rankId") REFERENCES "skill_ranks" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION, CONSTRAINT "FK_cad5a64685c1be599c10bb7fc7b" FOREIGN KEY ("levelId") REFERENCES "skill_levels" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION)`); + await queryRunner.query(`INSERT INTO "temporary_skills"("id", "name", "categoryId", "levelId", "rankId", "created", "createdBy", "updated", "updatedBy", "deleted", "deletedBy") SELECT "id", "name", "categoryId", "levelId", "rankId", "created", "createdBy", "updated", "updatedBy", "deleted", "deletedBy" FROM "skills"`); + await queryRunner.query(`DROP TABLE "skills"`); + await queryRunner.query(`ALTER TABLE "temporary_skills" RENAME TO "skills"`); + await queryRunner.query(`CREATE TABLE "temporary_projects" ("id" varchar PRIMARY KEY NOT NULL, "name" varchar NOT NULL, "iconName" varchar NOT NULL, "order" integer NOT NULL, "summary" varchar NOT NULL, "repoUrl" varchar, "siteUrl" varchar, "statusId" integer NOT NULL, "created" datetime NOT NULL DEFAULT (datetime('now')), "createdBy" varchar, "updated" datetime NOT NULL DEFAULT (datetime('now')), "updatedBy" varchar, "deleted" datetime, "deletedBy" varchar, CONSTRAINT "REL_5a833dfa646b99b852c67dd859" UNIQUE ("statusId"), CONSTRAINT "FK_5a833dfa646b99b852c67dd8593" FOREIGN KEY ("statusId") REFERENCES "statuses" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION)`); + await queryRunner.query(`INSERT INTO "temporary_projects"("id", "name", "iconName", "order", "summary", "repoUrl", "siteUrl", "statusId", "created", "createdBy", "updated", "updatedBy", "deleted", "deletedBy") SELECT "id", "name", "iconName", "order", "summary", "repoUrl", "siteUrl", "statusId", "created", "createdBy", "updated", "updatedBy", "deleted", "deletedBy" FROM "projects"`); + await queryRunner.query(`DROP TABLE "projects"`); + await queryRunner.query(`ALTER TABLE "temporary_projects" RENAME TO "projects"`); + await queryRunner.query(`CREATE TABLE "temporary_experiences" ("id" varchar PRIMARY KEY NOT NULL, "profileId" varchar NOT NULL, "companyName" varchar NOT NULL, "companyGeneric" varchar NOT NULL, "title" varchar NOT NULL, "startDate" datetime NOT NULL, "endDate" datetime, "summary" varchar, "statusId" integer NOT NULL, "created" datetime NOT NULL DEFAULT (datetime('now')), "createdBy" varchar, "updated" datetime NOT NULL DEFAULT (datetime('now')), "updatedBy" varchar, "deleted" datetime, "deletedBy" varchar, CONSTRAINT "REL_23dfcd8f2d1b6585b848f840d5" UNIQUE ("statusId"), CONSTRAINT "FK_be01c61f0c549f2187b5c05c349" FOREIGN KEY ("profileId") REFERENCES "profiles" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION, CONSTRAINT "FK_23dfcd8f2d1b6585b848f840d5e" FOREIGN KEY ("statusId") REFERENCES "statuses" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION)`); + await queryRunner.query(`INSERT INTO "temporary_experiences"("id", "profileId", "companyName", "companyGeneric", "title", "startDate", "endDate", "summary", "statusId", "created", "createdBy", "updated", "updatedBy", "deleted", "deletedBy") SELECT "id", "profileId", "companyName", "companyGeneric", "title", "startDate", "endDate", "summary", "statusId", "created", "createdBy", "updated", "updatedBy", "deleted", "deletedBy" FROM "experiences"`); + await queryRunner.query(`DROP TABLE "experiences"`); + await queryRunner.query(`ALTER TABLE "temporary_experiences" RENAME TO "experiences"`); + await queryRunner.query(`CREATE TABLE "temporary_profiles" ("id" varchar PRIMARY KEY NOT NULL, "name" varchar NOT NULL, "headline" varchar NOT NULL, "summary" varchar NOT NULL, "statusId" integer NOT NULL, "userId" varchar NOT NULL, "created" datetime NOT NULL DEFAULT (datetime('now')), "createdBy" varchar, "updated" datetime NOT NULL DEFAULT (datetime('now')), "updatedBy" varchar, "deleted" datetime, "deletedBy" varchar, CONSTRAINT "REL_a0e37cd676d13d116f73d494ee" UNIQUE ("statusId"), CONSTRAINT "FK_a0e37cd676d13d116f73d494ee6" FOREIGN KEY ("statusId") REFERENCES "statuses" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION)`); + await queryRunner.query(`INSERT INTO "temporary_profiles"("id", "name", "headline", "summary", "statusId", "userId", "created", "createdBy", "updated", "updatedBy", "deleted", "deletedBy") SELECT "id", "name", "headline", "summary", "statusId", "userId", "created", "createdBy", "updated", "updatedBy", "deleted", "deletedBy" FROM "profiles"`); + await queryRunner.query(`DROP TABLE "profiles"`); + await queryRunner.query(`ALTER TABLE "temporary_profiles" RENAME TO "profiles"`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "profiles" RENAME TO "temporary_profiles"`); + await queryRunner.query(`CREATE TABLE "profiles" ("id" varchar PRIMARY KEY NOT NULL, "name" varchar NOT NULL, "headline" varchar NOT NULL, "summary" varchar NOT NULL, "statusId" integer NOT NULL, "userId" varchar NOT NULL, "created" datetime NOT NULL DEFAULT (datetime('now')), "createdBy" varchar, "updated" datetime NOT NULL DEFAULT (datetime('now')), "updatedBy" varchar, "deleted" datetime, "deletedBy" varchar, CONSTRAINT "REL_a0e37cd676d13d116f73d494ee" UNIQUE ("statusId"))`); + await queryRunner.query(`INSERT INTO "profiles"("id", "name", "headline", "summary", "statusId", "userId", "created", "createdBy", "updated", "updatedBy", "deleted", "deletedBy") SELECT "id", "name", "headline", "summary", "statusId", "userId", "created", "createdBy", "updated", "updatedBy", "deleted", "deletedBy" FROM "temporary_profiles"`); + await queryRunner.query(`DROP TABLE "temporary_profiles"`); + await queryRunner.query(`ALTER TABLE "experiences" RENAME TO "temporary_experiences"`); + await queryRunner.query(`CREATE TABLE "experiences" ("id" varchar PRIMARY KEY NOT NULL, "profileId" varchar NOT NULL, "companyName" varchar NOT NULL, "companyGeneric" varchar NOT NULL, "title" varchar NOT NULL, "startDate" datetime NOT NULL, "endDate" datetime, "summary" varchar, "statusId" integer NOT NULL, "created" datetime NOT NULL DEFAULT (datetime('now')), "createdBy" varchar, "updated" datetime NOT NULL DEFAULT (datetime('now')), "updatedBy" varchar, "deleted" datetime, "deletedBy" varchar, CONSTRAINT "REL_23dfcd8f2d1b6585b848f840d5" UNIQUE ("statusId"))`); + await queryRunner.query(`INSERT INTO "experiences"("id", "profileId", "companyName", "companyGeneric", "title", "startDate", "endDate", "summary", "statusId", "created", "createdBy", "updated", "updatedBy", "deleted", "deletedBy") SELECT "id", "profileId", "companyName", "companyGeneric", "title", "startDate", "endDate", "summary", "statusId", "created", "createdBy", "updated", "updatedBy", "deleted", "deletedBy" FROM "temporary_experiences"`); + await queryRunner.query(`DROP TABLE "temporary_experiences"`); + await queryRunner.query(`ALTER TABLE "projects" RENAME TO "temporary_projects"`); + await queryRunner.query(`CREATE TABLE "projects" ("id" varchar PRIMARY KEY NOT NULL, "name" varchar NOT NULL, "iconName" varchar NOT NULL, "order" integer NOT NULL, "summary" varchar NOT NULL, "repoUrl" varchar, "siteUrl" varchar, "statusId" integer NOT NULL, "created" datetime NOT NULL DEFAULT (datetime('now')), "createdBy" varchar, "updated" datetime NOT NULL DEFAULT (datetime('now')), "updatedBy" varchar, "deleted" datetime, "deletedBy" varchar, CONSTRAINT "REL_5a833dfa646b99b852c67dd859" UNIQUE ("statusId"))`); + await queryRunner.query(`INSERT INTO "projects"("id", "name", "iconName", "order", "summary", "repoUrl", "siteUrl", "statusId", "created", "createdBy", "updated", "updatedBy", "deleted", "deletedBy") SELECT "id", "name", "iconName", "order", "summary", "repoUrl", "siteUrl", "statusId", "created", "createdBy", "updated", "updatedBy", "deleted", "deletedBy" FROM "temporary_projects"`); + await queryRunner.query(`DROP TABLE "temporary_projects"`); + await queryRunner.query(`ALTER TABLE "skills" RENAME TO "temporary_skills"`); + await queryRunner.query(`CREATE TABLE "skills" ("id" varchar PRIMARY KEY NOT NULL, "name" varchar NOT NULL, "categoryId" varchar NOT NULL, "levelId" integer NOT NULL, "rankId" integer NOT NULL, "created" datetime NOT NULL DEFAULT (datetime('now')), "createdBy" varchar, "updated" datetime NOT NULL DEFAULT (datetime('now')), "updatedBy" varchar, "deleted" datetime, "deletedBy" varchar, CONSTRAINT "REL_168133e3158020b53392549d3e" UNIQUE ("rankId"))`); + await queryRunner.query(`INSERT INTO "skills"("id", "name", "categoryId", "levelId", "rankId", "created", "createdBy", "updated", "updatedBy", "deleted", "deletedBy") SELECT "id", "name", "categoryId", "levelId", "rankId", "created", "createdBy", "updated", "updatedBy", "deleted", "deletedBy" FROM "temporary_skills"`); + await queryRunner.query(`DROP TABLE "temporary_skills"`); + await queryRunner.query(`DROP TABLE "profiles"`); + await queryRunner.query(`DROP TABLE "experiences"`); + await queryRunner.query(`DROP TABLE "projects"`); + await queryRunner.query(`DROP TABLE "skills"`); + await queryRunner.query(`DROP TABLE "skill_ranks"`); + await queryRunner.query(`DROP TABLE "skill_levels"`); + await queryRunner.query(`DROP TABLE "skill_categories"`); + await queryRunner.query(`DROP TABLE "statuses"`); + } + +} diff --git a/api/src/database/seeds/skill_levels.ts b/api/src/database/seeds/skill_levels.ts new file mode 100644 index 0000000..7e7c944 --- /dev/null +++ b/api/src/database/seeds/skill_levels.ts @@ -0,0 +1,20 @@ +import { Seeder } from 'typeorm-extension'; +import { DataSource } from 'typeorm'; +import { SkillLevelEntity } from '../../skill/skill-level.entity' + +export default class SkillLevelsSeeder implements Seeder { + public async run(dataSource: DataSource): Promise { + const repository = dataSource.getRepository(SkillLevelEntity); + + const skillLevels = [ + { + name: 'Experienced' + }, + { + name: 'Familiar' + } + ] + + await repository.save(skillLevels); + } +} \ No newline at end of file diff --git a/api/src/database/seeds/skill_ranks.ts b/api/src/database/seeds/skill_ranks.ts new file mode 100644 index 0000000..cdc22a7 --- /dev/null +++ b/api/src/database/seeds/skill_ranks.ts @@ -0,0 +1,26 @@ +import { Seeder } from 'typeorm-extension'; +import { DataSource } from 'typeorm'; +import { SkillRankEntity } from '../../skill/skill-rank.entity' + +export default class SkillRanksSeeder implements Seeder { + public async run(dataSource: DataSource): Promise { + const repository = dataSource.getRepository(SkillRankEntity); + + const skillRanks = [ + { + name: 'High', + weight: 3 + }, + { + name: 'Medium', + weight: 2 + }, + { + name: 'Low', + weight: 1 + } + ] + + await repository.save(skillRanks); + } +} \ No newline at end of file diff --git a/api/src/database/seeds/statuses.ts b/api/src/database/seeds/statuses.ts new file mode 100644 index 0000000..69e76b7 --- /dev/null +++ b/api/src/database/seeds/statuses.ts @@ -0,0 +1,20 @@ +import { Seeder } from 'typeorm-extension'; +import { DataSource } from 'typeorm'; +import { StatusEntity } from '../../status/status.entity' + +export default class StatusesSeeder implements Seeder { + public async run(dataSource: DataSource): Promise { + const repository = dataSource.getRepository(StatusEntity); + + const statuses = [ + { + name: 'Draft' + }, + { + name: 'Published' + } + ] + + await repository.save(statuses); + } +} \ No newline at end of file diff --git a/api/src/experience/experience.controller.spec.ts b/api/src/experience/experience.controller.spec.ts new file mode 100644 index 0000000..e69de29 diff --git a/api/src/experience/experience.controller.ts b/api/src/experience/experience.controller.ts new file mode 100644 index 0000000..2d49045 --- /dev/null +++ b/api/src/experience/experience.controller.ts @@ -0,0 +1,69 @@ +import { Body, Controller, Delete, Get, HttpException, Param, Post, Put } from "@nestjs/common"; +import { ExperienceService } from "./experience.service"; +import { CustomError } from "@noahspan/noahspan-modules"; +import { ExperienceDto } from "./experience.dto"; + +@Controller('experiences') +export class ExperienceController { + constructor(private readonly experienceService: ExperienceService) {} + + @Get(':id') + async find(@Param('id') id: string) { + try { + return await this.experienceService.find(id); + } catch (error) { + const customError = error as CustomError; + + throw new HttpException(customError.message, customError.statusCode); + } + } + + @Get() + async findAll() { + try { + return await this.experienceService.findAll(); + } catch (error) { + const customError = error as CustomError; + + throw new HttpException(customError.message, customError.statusCode); + } + } + + @Post() + async create(@Body() experienceDto: ExperienceDto): Promise { + try { + return await this.experienceService.create(experienceDto); + } catch (error) { + const customError = error as CustomError; + console.log(error) + throw new HttpException(customError.message, customError.statusCode) + } + } + + @Put(':id') + async update( + @Param('id') id: string, + @Body() ExperienceDto: ExperienceDto + ) { + try { + return await this.experienceService.update(id, ExperienceDto); + } catch (error) { + const customError = error as CustomError; + + throw new HttpException(customError.message, customError.statusCode) + } + } + + @Delete(':id') + async delete( + @Param('id') id: string, + ) { + try { + return await this.experienceService.delete(id); + } catch (error) { + const customError = error as CustomError; + + throw new HttpException(customError.message, customError.statusCode) + } + } +} \ No newline at end of file diff --git a/api/src/experience/experience.dto.ts b/api/src/experience/experience.dto.ts new file mode 100644 index 0000000..05112ed --- /dev/null +++ b/api/src/experience/experience.dto.ts @@ -0,0 +1,13 @@ +export class ExperienceDto { + companyName: string; + companyNameGeneric: string; + location: string; + title: string; + startDate: Date; + endDate?: Date; + summary: string; + skills?: []; + createdBy?: string; + updatedBy?: string; + deletedBy?: string; +} \ No newline at end of file diff --git a/api/src/experience/experience.entity.ts b/api/src/experience/experience.entity.ts new file mode 100644 index 0000000..0bb8d3a --- /dev/null +++ b/api/src/experience/experience.entity.ts @@ -0,0 +1,63 @@ +import { ProfileEntity } from '../profile/profile.entity' +import { SkillEntity } from 'src/skill/skill.entity'; +import { StatusEntity } from 'src/status/status.entity'; +import { Entity, PrimaryGeneratedColumn, Column, JoinColumn, ManyToOne, OneToMany, OneToOne, CreateDateColumn, UpdateDateColumn, DeleteDateColumn } from 'typeorm'; + +@Entity({ name: 'experiences' }) +export class ExperienceEntity { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column() + profileId: string; + + @Column() + companyName: string; + + @Column() + companyGeneric: string + + @Column() + title: string; + + @Column() + startDate: Date; + + @Column({ nullable: true }) + endDate: Date; + + @Column({ nullable: true }) + summary: string | null + + @Column() + statusId: number; + + @CreateDateColumn() + created: Date; + + @Column({ nullable: true }) + createdBy: string; + + @UpdateDateColumn() + updated: Date; + + @Column({ nullable: true }) + updatedBy: string; + + @DeleteDateColumn() + deleted: Date; + + @Column({ nullable: true }) + deletedBy: string; + + @OneToMany(() => SkillEntity, (skill: SkillEntity) => skill.name ) + skills: SkillEntity[]; + + @ManyToOne(() => ProfileEntity, (profile: ProfileEntity) => profile.experiences) + @JoinColumn({ name: 'profileId' }) + profile: ProfileEntity; + + @OneToOne(() => StatusEntity, (status: StatusEntity) => status.name ) + @JoinColumn({ name: 'statusId' }) + status: StatusEntity; +} \ No newline at end of file diff --git a/api/src/experience/experience.module.ts b/api/src/experience/experience.module.ts new file mode 100644 index 0000000..f1c9c5e --- /dev/null +++ b/api/src/experience/experience.module.ts @@ -0,0 +1,16 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from "@nestjs/typeorm"; +import { ExperienceEntity } from "./experience.entity"; +import { ExperienceController } from "./experience.controller"; +import { ExperienceService } from "./experience.service"; + + +@Module({ + imports: [ + TypeOrmModule.forFeature([ExperienceEntity]) + ], + controllers: [ExperienceController], + exports: [ExperienceService], + providers: [ExperienceService] +}) +export class ExperienceModule {} \ No newline at end of file diff --git a/api/src/experience/experience.service.spec.ts b/api/src/experience/experience.service.spec.ts new file mode 100644 index 0000000..7e28be3 --- /dev/null +++ b/api/src/experience/experience.service.spec.ts @@ -0,0 +1,51 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from "@nestjs/typeorm"; +import { ExperienceService } from "./experience.service" +import { ExperienceEntity } from './experience.entity' + +describe('ExperienceService', () => { + let service: ExperienceService; + + const mockExperienceRepository = { + delete: jest.fn(), + find: jest.fn(), + findOneBy: jest.fn(), + save: jest.fn() + } + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ExperienceService, + { + provide: getRepositoryToken(ExperienceEntity), + useValue: mockExperienceRepository + } + ] + }).compile(); + + service = module.get(ExperienceService); + }) + + it('should be defined', () => { + expect(service).toBeDefined(); + }) + + it('find => should find one experience by id', async () => { + + }) + + it('findAll => should find all experiences', () => { + + }) + + it('create => should create a new experience', () => { + + }) + + it('update => should update an experience', () => { + + }) + + it('delete => should delete an experience') +}) \ No newline at end of file diff --git a/api/src/experience/experience.service.ts b/api/src/experience/experience.service.ts new file mode 100644 index 0000000..2bc5953 --- /dev/null +++ b/api/src/experience/experience.service.ts @@ -0,0 +1,43 @@ +import { Injectable } from '@nestjs/common'; +import { ExperienceEntity } from './experience.entity'; +import { ExperienceDto } from './experience.dto'; +import { InjectRepository } from "@nestjs/typeorm"; +import { DeleteResult, Repository } from "typeorm"; + +@Injectable() +export class ExperienceService { + constructor( + @InjectRepository(ExperienceEntity) private readonly experienceRepository: Repository + ) {} + + async find(id: string): Promise { + return await this.experienceRepository.findOne({ + where: { id: id }, + relations: ['profile', 'status'] + }) + } + + async findAll(): Promise { + return await this.experienceRepository.find({ + relations: ['profile', 'status'] + }); + } + + async create(experience: ExperienceDto): Promise { + return await this.experienceRepository.save(experience); + } + + async update( + id: string, + experienceDto: ExperienceDto + ): Promise { + const experienceEntity: ExperienceEntity = await this.experienceRepository.findOneBy({ id }) + const experienceEntityUpdated = Object.assign(experienceEntity, experienceDto) + + return await this.experienceRepository.save(experienceEntityUpdated) + } + + async delete(id: string): Promise { + return await this.experienceRepository.delete({ id }) + } +} \ No newline at end of file diff --git a/api/src/filters/http-exception.filter.ts b/api/src/filters/http-exception.filter.ts new file mode 100644 index 0000000..a1942a0 --- /dev/null +++ b/api/src/filters/http-exception.filter.ts @@ -0,0 +1,25 @@ +import { + ExceptionFilter, + Catch, + ArgumentsHost, + HttpException +} from '@nestjs/common'; +import { Request, Response } from 'express'; + +@Catch(HttpException) +export class HttpExceptionFilter implements ExceptionFilter { + catch(excpetion: HttpException, host: ArgumentsHost) { + const ctx = host.switchToHttp(); + const response = ctx.getResponse(); + const request = ctx.getRequest(); + const status = excpetion.getStatus(); + + response.status(status).json({ + name: excpetion.cause, + message: excpetion.message, + statusCode: status, + timestamp: new Date().toISOString(), + path: request.url + }); + } +} diff --git a/api/src/index.css b/api/src/index.css new file mode 100644 index 0000000..05ebb04 --- /dev/null +++ b/api/src/index.css @@ -0,0 +1,3 @@ +body { + background-color: #f2f2f2; +} \ No newline at end of file diff --git a/api/src/main.ts b/api/src/main.ts new file mode 100644 index 0000000..50361e2 --- /dev/null +++ b/api/src/main.ts @@ -0,0 +1,27 @@ +import { NestFactory } from '@nestjs/core'; +import { AppModule } from './app.module'; +import { HttpService } from '@nestjs/axios'; +import { HttpExceptionFilter } from './filters/http-exception.filter'; +import { InternalServerErrorException } from '@nestjs/common'; + +async function bootstrap() { + const httpService = new HttpService(); + const app = await NestFactory.create(AppModule); + + app.enableCors(); + app.setGlobalPrefix('api'); + app.useGlobalFilters(new HttpExceptionFilter()); + httpService.axiosRef.interceptors.response.use( + (response) => { + return response; + }, + (error) => { + console.error('Internal server error exception', error); + + throw new InternalServerErrorException(); + } + ); + + await app.listen(3000); +} +bootstrap(); diff --git a/api/src/profile/profile.controller.spec.ts b/api/src/profile/profile.controller.spec.ts new file mode 100644 index 0000000..e69de29 diff --git a/api/src/profile/profile.controller.ts b/api/src/profile/profile.controller.ts new file mode 100644 index 0000000..6faf421 --- /dev/null +++ b/api/src/profile/profile.controller.ts @@ -0,0 +1,87 @@ +import { + Body, + Controller, + Delete, + Get, + HttpException, + Param, + Post, + Put, + UseGuards +} from '@nestjs/common'; +import { ProfileDto } from './profile.dto'; +import { ProfileEntity } from './profile.entity'; +import { ProfileService } from './profile.service'; +import { AuthGuard, CustomError, Public } from '@noahspan/noahspan-modules'; + +@Controller('profiles') +export class ProfileController { + constructor(private readonly profileService: ProfileService) {} + + @Get(':id') + // @UseGuards(AuthGuard) + async find( + @Param('id') id: string, + ) { + try { + return await this.profileService.find(id); + } catch (error) { + const customError = error as CustomError; + console.log(error) + throw new HttpException(customError.message, customError.statusCode); + } + } + + @Get() + async findAll() { + try { + return await this.profileService.findAll(); + } catch (error) { + const customError = error as CustomError; + + throw new HttpException(customError.message, customError.statusCode); + } + } + + @Post() + // @UseGuards(AuthGuard) + async create(@Body() profileDto: ProfileDto) { + try { + console.log(profileDto) + return await this.profileService.create(profileDto); + } catch (error) { + const customError = error as CustomError; + console.log(customError) + throw new HttpException(customError.message, customError.statusCode); + } + } + + @Put(':id') + @UseGuards(AuthGuard) + async update( + @Param('id') id: string, + @Body() profileDto: ProfileDto + ) { + try { + return await this.profileService.update(id, profileDto); + } catch (error) { + const customError = error as CustomError; + + throw new HttpException(customError.message, customError.statusCode); + } + } + + @Delete(':id') + @UseGuards(AuthGuard) + async delete( + @Param('id') id: string + ) { + try { + return await this.profileService.delete(id); + } catch (error) { + const customError = error as CustomError; + + throw new HttpException(customError.message, customError.statusCode); + } + } +} diff --git a/api/src/profile/profile.dto.ts b/api/src/profile/profile.dto.ts new file mode 100644 index 0000000..18de0ad --- /dev/null +++ b/api/src/profile/profile.dto.ts @@ -0,0 +1,10 @@ +export class ProfileDto { + name: string; + headline: string; + summary: string; + userId: string; + statusId: number; + createdBy?: string; + updatedBy?: string; + deletedBy?: string; +} \ No newline at end of file diff --git a/api/src/profile/profile.entity.ts b/api/src/profile/profile.entity.ts new file mode 100644 index 0000000..7dcd4b9 --- /dev/null +++ b/api/src/profile/profile.entity.ts @@ -0,0 +1,53 @@ +import { ExperienceEntity } from 'src/experience/experience.entity'; +import { SkillEntity } from 'src/skill/skill.entity'; +import { StatusEntity } from 'src/status/status.entity'; +import { Entity, PrimaryGeneratedColumn, Column, OneToMany, CreateDateColumn, UpdateDateColumn, DeleteDateColumn, OneToOne, JoinColumn } from 'typeorm'; + +@Entity({ name: 'profiles' }) +export class ProfileEntity { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column() + name: string; + + @Column() + headline: string; + + @Column() + summary: string; + + @Column() + statusId: number; + + @Column() + userId: string; + + @CreateDateColumn() + created: Date; + + @Column({ nullable: true }) + createdBy: string; + + @UpdateDateColumn() + updated: Date; + + @Column({ nullable: true }) + updatedBy: string + + @DeleteDateColumn() + deleted: Date; + + @Column({ nullable: true }) + deletedBy: string; + + @OneToMany(() => ExperienceEntity, (experience: ExperienceEntity) => experience.profile) + experiences: ExperienceEntity[] + + @OneToMany(() => SkillEntity, (skill: SkillEntity) => skill.name ) + skills: SkillEntity[]; + + @OneToOne(() => StatusEntity, (status: StatusEntity) => status.name ) + @JoinColumn({ name: 'statusId' }) + status: StatusEntity; +} \ No newline at end of file diff --git a/api/src/profile/profile.module.ts b/api/src/profile/profile.module.ts new file mode 100644 index 0000000..a22b233 --- /dev/null +++ b/api/src/profile/profile.module.ts @@ -0,0 +1,15 @@ +import { Module } from "@nestjs/common"; +import { TypeOrmModule } from "@nestjs/typeorm"; +import { ProfileController } from "./profile.controller"; +import { ProfileEntity } from "./profile.entity"; +import { ProfileService } from "./profile.service"; + +@Module({ + imports: [ + TypeOrmModule.forFeature([ProfileEntity]) + ], + controllers: [ProfileController], + exports: [ProfileService], + providers: [ProfileService] +}) +export class ProfileModule {} \ No newline at end of file diff --git a/api/src/profile/profile.service.spec.ts b/api/src/profile/profile.service.spec.ts new file mode 100644 index 0000000..e69de29 diff --git a/api/src/profile/profile.service.ts b/api/src/profile/profile.service.ts new file mode 100644 index 0000000..accc256 --- /dev/null +++ b/api/src/profile/profile.service.ts @@ -0,0 +1,46 @@ +import { Injectable } from '@nestjs/common'; +import { ProfileEntity } from './profile.entity'; +import { InjectRepository } from '@nestjs/typeorm'; +import { DeleteResult, Repository } from 'typeorm'; +import { ProfileDto } from './profile.dto'; + +@Injectable() +export class ProfileService { + constructor( + @InjectRepository(ProfileEntity) private readonly profileRepository: Repository, + ) {} + + async find(id: string): Promise { + const profile = await this.profileRepository.findOne({ + where: { id: id }, + relations: ['status'] + }); + + console.log(profile); + return profile; + } + + async findAll(): Promise { + return await this.profileRepository.find({ + relations: ['status'] + }); + } + + async create(profile: ProfileDto): Promise { + return await this.profileRepository.save(profile); + } + + async update( + id: string, + profileDto: ProfileDto + ): Promise { + const profileEntity: ProfileEntity = await this.profileRepository.findOneBy({ id }) + const profileEntityUpdated = Object.assign(profileEntity, profileDto) + + return await this.profileRepository.save(profileEntityUpdated); + } + + async delete(id: string): Promise { + return await this.profileRepository.delete({ id }); + } +} diff --git a/api/src/project/project.controller.spec.ts b/api/src/project/project.controller.spec.ts new file mode 100644 index 0000000..a923b1e --- /dev/null +++ b/api/src/project/project.controller.spec.ts @@ -0,0 +1,70 @@ +import { Test, TestingModule } from "@nestjs/testing"; +import { ProjectController } from "./project.controller" +import { ProjectService } from "./project.service"; + + +describe('ProjectController', () => { + let controller: ProjectController; + + const mockProjectService = { + create: jest.fn(), + delete: jest.fn(), + find: jest.fn(), + findAll: jest.fn(), + update: jest.fn() + } + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [ProjectController], + providers: [ + { + provide: ProjectService, + useValue: mockProjectService + } + ] + }).compile(); + + controller = module.get(ProjectController); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); + + it('find => should find a project by id', async () => { + const id: string = '' + }); + + it('find => should fail to find a project by id', async () => { + const id: string = '' + }); + + it('fildAll => should fail to find all projects', async () => { + + }); + + it('create => should create a new project', async () => { + + }) + + it('create => should fail to create a new project', async () => { + + }) + + it('update => should update an existing project', async () => { + + }) + + it('update => should fail to update an existing project', async () => { + + }) + + it('delete => should delete an existing project', async () => { + + }) + + it('delete => should failt to delete an existing project', async () => { + + }) +}) \ No newline at end of file diff --git a/api/src/project/project.controller.ts b/api/src/project/project.controller.ts new file mode 100644 index 0000000..39f26f4 --- /dev/null +++ b/api/src/project/project.controller.ts @@ -0,0 +1,87 @@ +import { + Body, + Controller, + Delete, + Get, + HttpException, + Param, + Post, + Put, + UseGuards +} from '@nestjs/common'; +import { ProjectDto } from './project.dto'; +import { ProjectEntity } from './project.entity'; +import { ProjectService } from './project.service'; +import { AuthGuard, CustomError, Public } from '@noahspan/noahspan-modules'; + +@Controller('projects') +export class ProjectController { + constructor(private readonly projectService: ProjectService) {} + + @Get(':id') + @UseGuards(AuthGuard) + async find( + @Param('id') id: string, + ) { + try { + return await this.projectService.find(id); + } catch (error) { + const customError = error as CustomError; + + throw new HttpException(customError.message, customError.statusCode); + } + } + + @Get() + async findAll() { + try { + return await this.projectService.findAll(); + } catch (error) { + const customError = error as CustomError; + + throw new HttpException(customError.message, customError.statusCode); + } + } + + @Post() + // @UseGuards(AuthGuard) + async create(@Body() projectDto: ProjectDto) { + try { + console.log(projectDto) + return await this.projectService.create(projectDto); + } catch (error) { + const customError = error as CustomError; + console.log(customError) + throw new HttpException(customError.message, customError.statusCode); + } + } + + @Put(':id') + @UseGuards(AuthGuard) + async update( + @Param('id') id: string, + @Body() projectDto: ProjectDto + ) { + try { + return await this.projectService.update(id, projectDto); + } catch (error) { + const customError = error as CustomError; + + throw new HttpException(customError.message, customError.statusCode); + } + } + + @Delete(':id') + @UseGuards(AuthGuard) + async delete( + @Param('id') id: string + ) { + try { + return await this.projectService.delete(id); + } catch (error) { + const customError = error as CustomError; + + throw new HttpException(customError.message, customError.statusCode); + } + } +} diff --git a/api/src/project/project.dto.ts b/api/src/project/project.dto.ts new file mode 100644 index 0000000..b4870bb --- /dev/null +++ b/api/src/project/project.dto.ts @@ -0,0 +1,13 @@ +export class ProjectDto { + id: string; + name: string; + icon: string; + order: number; + summary: string; + repoUrl?: string; + siteUrl?: string; + skills?: []; + createdBy?: string; + updatedBy?: string; + deletedBy?: string; +} \ No newline at end of file diff --git a/api/src/project/project.entity.ts b/api/src/project/project.entity.ts new file mode 100644 index 0000000..f3fab90 --- /dev/null +++ b/api/src/project/project.entity.ts @@ -0,0 +1,55 @@ +import { SkillEntity } from 'src/skill/skill.entity'; +import { StatusEntity } from 'src/status/status.entity'; +import { Entity, PrimaryGeneratedColumn, Column, OneToMany, OneToOne, JoinColumn, CreateDateColumn, UpdateDateColumn, DeleteDateColumn } from 'typeorm'; + +@Entity({ name: 'projects' }) +export class ProjectEntity { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column() + name: string + + @Column() + iconName: string + + @Column() + order: number; + + @Column() + summary: string; + + @Column({ nullable: true }) + repoUrl?: string; + + @Column({ nullable: true }) + siteUrl?: string; + + @Column() + statusId: string; + + @CreateDateColumn() + created: Date; + + @Column({ nullable: true }) + createdBy: string; + + @UpdateDateColumn() + updated: Date; + + @Column({ nullable: true }) + updatedBy: string; + + @DeleteDateColumn() + deleted: Date; + + @Column({ nullable: true }) + deletedBy: string + + @OneToMany(() => SkillEntity, (skill: SkillEntity) => skill.name ) + skills: SkillEntity[]; + + @OneToOne(() => StatusEntity, (status: StatusEntity) => status.name ) + @JoinColumn({ name: 'statusId' }) + status: StatusEntity; +} diff --git a/api/src/project/project.module.ts b/api/src/project/project.module.ts new file mode 100644 index 0000000..ff61068 --- /dev/null +++ b/api/src/project/project.module.ts @@ -0,0 +1,16 @@ +import { Module } from '@nestjs/common'; +import { ProjectController } from './project.controller'; +import { ProjectService } from './project.service'; +import { ProjectEntity } from './project.entity'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +@Module({ + imports: [ + ProjectModule, + TypeOrmModule.forFeature([ProjectEntity]) + ], + controllers: [ProjectController], + providers: [ProjectService] +}) +export class ProjectModule {} diff --git a/api/src/project/project.service.spec.ts b/api/src/project/project.service.spec.ts new file mode 100644 index 0000000..25aad79 --- /dev/null +++ b/api/src/project/project.service.spec.ts @@ -0,0 +1,54 @@ +import { Test, TestingModule } from "@nestjs/testing"; +import { ProjectService } from "./project.service" +import { ProjectEntity } from "./project.entity"; +import { getRepositoryToken } from "@nestjs/typeorm"; + + +describe('ProjectService', () => { + let service: ProjectService; + + const mockProjectRepository = { + delete: jest.fn(), + find: jest.fn(), + findOneBy: jest.fn(), + save: jest.fn() + } + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ProjectService, + { + provide: getRepositoryToken(ProjectEntity), + useValue: mockProjectRepository + } + ] + }).compile(); + + service = module.get(ProjectService); + }) + + it('should be defined', () => { + expect(service).toBeDefined(); + }) + + it('find => should find one project by id', async () => { + const id: string = '' + }) + + it('findAll => should find all projects', async () => { + + }) + + it('create => should create a new project', async () => { + + }) + + it('update => should update a project', async () => { + + }) + + it('delete => should delete a project', async () => { + + }) +}) \ No newline at end of file diff --git a/api/src/project/project.service.ts b/api/src/project/project.service.ts new file mode 100644 index 0000000..213c0c1 --- /dev/null +++ b/api/src/project/project.service.ts @@ -0,0 +1,46 @@ +import { Injectable } from '@nestjs/common'; +import { ProjectEntity } from './project.entity'; +import { InjectRepository } from '@nestjs/typeorm'; +import { DeleteResult, Repository } from 'typeorm'; +import { ProjectDto } from './project.dto'; + +@Injectable() +export class ProjectService { + constructor( + @InjectRepository(ProjectEntity) private readonly projectRepository: Repository, + ) {} + + async find(id: string): Promise { + const project = await this.projectRepository.findOne({ + where: { id: id }, + relations: ['status'] + }); + + console.log(project); + return project; + } + + async findAll(): Promise { + return await this.projectRepository.find({ + relations: ['status'] + }); + } + + async create(project: ProjectDto): Promise { + return await this.projectRepository.save(project); + } + + async update( + id: string, + projectDto: ProjectDto + ): Promise { + const projectEntity: ProjectEntity = await this.projectRepository.findOneBy({ id }) + const projectEntityUpdated = Object.assign(projectEntity, projectDto) + + return await this.projectRepository.save(projectEntityUpdated); + } + + async delete(id: string): Promise { + return await this.projectRepository.delete({ id }); + } +} diff --git a/api/src/skill/skill-category.controller.ts b/api/src/skill/skill-category.controller.ts new file mode 100644 index 0000000..3913924 --- /dev/null +++ b/api/src/skill/skill-category.controller.ts @@ -0,0 +1,89 @@ +import { + Body, + Controller, + Delete, + Get, + HttpException, + Param, + Post, + Put, + UseGuards +} from '@nestjs/common'; +import { SkillCategoryDto } from './skill-category.dto'; +import { SkillCategoryEntity } from './skill-category.entity'; +import { SkillCategoryService } from './skill-category.service'; +import { AuthGuard, CustomError, Public } from '@noahspan/noahspan-modules'; + +@Controller('skill-categories') +export class SkillCategoryController { + constructor(private readonly skillCategoryService: SkillCategoryService) {} + + @Get(':id') + @UseGuards(AuthGuard) + async find( + @Param('id') id: string, + ) { + try { + return await this.skillCategoryService.find(id); + } catch (error) { + const customError = error as CustomError; + + throw new HttpException(customError.message, customError.statusCode); + } + } + + @Get() + async findAll() { + try { + return await this.skillCategoryService.findAll(); + } catch (error) { + console.log(error) + const customError = error as CustomError; + + throw new HttpException(customError.message, customError.statusCode); + } + } + + @Post() + // @UseGuards(AuthGuard) + async create(@Body() skillCategoryDto: SkillCategoryDto) { + try { + console.log(skillCategoryDto) + return await this.skillCategoryService.create(skillCategoryDto); + } catch (error) { + console.log(error) + const customError = error as CustomError; + + throw new HttpException(customError.message, customError.statusCode); + } + } + + @Put(':id') + @UseGuards(AuthGuard) + async update( + @Param('id') id: string, + @Body() skillCategoryDto: SkillCategoryDto + ) { + try { + return await this.skillCategoryService.update(id, skillCategoryDto); + } catch (error) { + const customError = error as CustomError; + + throw new HttpException(customError.message, customError.statusCode); + } + } + + @Delete(':id') + // @UseGuards(AuthGuard) + async delete( + @Param('id') id: string + ) { + try { + return await this.skillCategoryService.delete(id); + } catch (error) { + const customError = error as CustomError; + + throw new HttpException(customError.message, customError.statusCode); + } + } +} diff --git a/api/src/skill/skill-category.dto.ts b/api/src/skill/skill-category.dto.ts new file mode 100644 index 0000000..9fddcd2 --- /dev/null +++ b/api/src/skill/skill-category.dto.ts @@ -0,0 +1,6 @@ +export class SkillCategoryDto { + name: string; + createdBy?: string; + updatedBy?: string; + deletedBy?: string; +} \ No newline at end of file diff --git a/api/src/skill/skill-category.entity.ts b/api/src/skill/skill-category.entity.ts new file mode 100644 index 0000000..4caf6da --- /dev/null +++ b/api/src/skill/skill-category.entity.ts @@ -0,0 +1,33 @@ +import { Entity, PrimaryGeneratedColumn, Column, OneToMany, ManyToOne, JoinColumn, CreateDateColumn, UpdateDateColumn, DeleteDateColumn } from 'typeorm'; +import { SkillEntity } from 'src/skill/skill.entity'; +import { SkillLevelEntity } from 'src/skill/skill-level.entity'; + +@Entity({ name: 'skill_categories' }) +export class SkillCategoryEntity { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column() + name: string; + + @CreateDateColumn() + created: Date; + + @Column({ nullable: true }) + createdBy: string; + + @UpdateDateColumn() + updated: Date; + + @Column({ nullable: true }) + updatedBy: string; + + @DeleteDateColumn() + deleted: Date; + + @Column({ nullable: true }) + deletedBy: string; + + @OneToMany(() => SkillEntity, (skill: SkillEntity) => skill.name) + skills: SkillEntity[]; +} \ No newline at end of file diff --git a/api/src/skill/skill-category.service.ts b/api/src/skill/skill-category.service.ts new file mode 100644 index 0000000..b10d5f0 --- /dev/null +++ b/api/src/skill/skill-category.service.ts @@ -0,0 +1,38 @@ +import { Injectable } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { SkillCategoryDto } from './skill-category.dto'; +import { SkillCategoryEntity } from './skill-category.entity'; +import { DeleteResult, Repository } from 'typeorm'; + +@Injectable() +export class SkillCategoryService { + constructor( + @InjectRepository(SkillCategoryEntity) private readonly skillCategoryRepository: Repository + ) {} + + async find(id: string): Promise { + return await this.skillCategoryRepository.findOneBy({ id }) + } + + async findAll(): Promise { + return await this.skillCategoryRepository.find(); + } + + async create(skillCategory: SkillCategoryDto): Promise { + return await this.skillCategoryRepository.save(skillCategory) + } + + async update( + id: string, + skillCategoryDto: SkillCategoryDto + ): Promise { + const skillCategoryEntity: SkillCategoryEntity = await this.skillCategoryRepository.findOneBy({ id }) + const skillEntityUpdated = Object.assign(skillCategoryEntity, skillCategoryDto) + + return await this.skillCategoryRepository.save(skillEntityUpdated) + } + + async delete(id: string): Promise { + return await this.skillCategoryRepository.delete({ id }) + } +} \ No newline at end of file diff --git a/api/src/skill/skill-level.controller.ts b/api/src/skill/skill-level.controller.ts new file mode 100644 index 0000000..eb3c87e --- /dev/null +++ b/api/src/skill/skill-level.controller.ts @@ -0,0 +1,24 @@ +import { + Controller, + Get, + HttpException, +} from '@nestjs/common'; +import { SkillLevelService } from './skill-level.service'; +import { AuthGuard, CustomError, Public } from '@noahspan/noahspan-modules'; + +@Controller('skill-levels') +export class SkillLevelController { + constructor(private readonly skillLevelService: SkillLevelService) {} + + @Get() + async findAll() { + try { + return await this.skillLevelService.findAll(); + } catch (error) { + console.log(error) + const customError = error as CustomError; + + throw new HttpException(customError.message, customError.statusCode); + } + } +} diff --git a/api/src/skill/skill-level.entity.ts b/api/src/skill/skill-level.entity.ts new file mode 100644 index 0000000..1db45a1 --- /dev/null +++ b/api/src/skill/skill-level.entity.ts @@ -0,0 +1,33 @@ +import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn, ManyToMany, JoinTable, OneToMany, CreateDateColumn, UpdateDateColumn, DeleteDateColumn } from 'typeorm'; +import { SkillEntity } from './skill.entity'; + +@Entity({ name: 'skill_levels' }) +export class SkillLevelEntity { + @PrimaryGeneratedColumn() + id: number; + + @Column() + name: string; + + @CreateDateColumn() + created: Date; + + @Column({ nullable: true }) + createdBy: string; + + @UpdateDateColumn() + updated: Date; + + @Column({ nullable: true }) + updatedBy: string; + + @DeleteDateColumn() + deleted: Date; + + @Column({ nullable: true }) + deletedBy: string; + + @OneToMany(() => SkillEntity, (skill: SkillEntity) => skill.name) + skills: SkillEntity[]; +} + diff --git a/api/src/skill/skill-level.service.ts b/api/src/skill/skill-level.service.ts new file mode 100644 index 0000000..52016df --- /dev/null +++ b/api/src/skill/skill-level.service.ts @@ -0,0 +1,18 @@ +import { Injectable } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { SkillDto } from './skill.dto'; +import { SkillEntity } from './skill.entity'; +import { DeleteResult, Repository } from 'typeorm'; +import { SkillLevelEntity } from "./skill-level.entity"; +import { SkillRankEntity } from "./skill-rank.entity"; + +@Injectable() +export class SkillLevelService { + constructor( + @InjectRepository(SkillLevelEntity) private readonly skillLevelRepository: Repository + ) {} + + async findAll(): Promise { + return await this.skillLevelRepository.find(); + } +} \ No newline at end of file diff --git a/api/src/skill/skill-rank.controller.ts b/api/src/skill/skill-rank.controller.ts new file mode 100644 index 0000000..1d2eefb --- /dev/null +++ b/api/src/skill/skill-rank.controller.ts @@ -0,0 +1,24 @@ +import { + Controller, + Get, + HttpException, +} from '@nestjs/common'; +import { SkillRankService } from './skill-rank.service'; +import { CustomError } from '@noahspan/noahspan-modules'; + +@Controller('skill-ranks') +export class SkillRankController { + constructor(private readonly skillRankService: SkillRankService) {} + + @Get() + async findAll() { + try { + return await this.skillRankService.findAll(); + } catch (error) { + console.log(error) + const customError = error as CustomError; + + throw new HttpException(customError.message, customError.statusCode); + } + } +} diff --git a/api/src/skill/skill-rank.entity.ts b/api/src/skill/skill-rank.entity.ts new file mode 100644 index 0000000..24f5d30 --- /dev/null +++ b/api/src/skill/skill-rank.entity.ts @@ -0,0 +1,31 @@ +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, DeleteDateColumn } from 'typeorm' + +@Entity({ name: 'skill_ranks' }) +export class SkillRankEntity { + @PrimaryGeneratedColumn() + id: number; + + @Column() + name: string; + + @Column() + weight: number; + + @CreateDateColumn() + created: Date; + + @Column({ nullable: true }) + createdBy: string; + + @UpdateDateColumn() + updated: Date; + + @Column({ nullable: true }) + updatedBy: string; + + @DeleteDateColumn() + deleted: Date; + + @Column({ nullable: true }) + deletedBy: string; +} \ No newline at end of file diff --git a/api/src/skill/skill-rank.service.ts b/api/src/skill/skill-rank.service.ts new file mode 100644 index 0000000..8e5d123 --- /dev/null +++ b/api/src/skill/skill-rank.service.ts @@ -0,0 +1,18 @@ +import { Injectable } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { SkillDto } from './skill.dto'; +import { SkillEntity } from './skill.entity'; +import { DeleteResult, Repository } from 'typeorm'; +import { SkillLevelEntity } from "./skill-level.entity"; +import { SkillRankEntity } from "./skill-rank.entity"; + +@Injectable() +export class SkillRankService { + constructor( + @InjectRepository(SkillRankEntity) private readonly skillRankRepository: Repository + ) {} + + async findAll(): Promise { + return await this.skillRankRepository.find(); + } +} \ No newline at end of file diff --git a/api/src/skill/skill.controller.spec.ts b/api/src/skill/skill.controller.spec.ts new file mode 100644 index 0000000..3724767 --- /dev/null +++ b/api/src/skill/skill.controller.spec.ts @@ -0,0 +1,27 @@ +import { Test, TestingModule } from "@nestjs/testing"; +import { getRepositoryToken } from "@nestjs/typeorm"; +import { ProjectService } from "src/project/project.service"; +import { SkillSerivce } from './skill.service' +import { SkillEntity } from "./skill.entity"; + +describe('SkillService', () => { + let service: SkillService; + + const mockSkillRepository = { + + } + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ProjectService, + { + provide: getRepositoryToken(SkillEntity), + useValue: mockSkillRepository + } + ] + }).compile(); + + service = module.get(SkillService); + }) +}) \ No newline at end of file diff --git a/api/src/skill/skill.controller.ts b/api/src/skill/skill.controller.ts new file mode 100644 index 0000000..341a3da --- /dev/null +++ b/api/src/skill/skill.controller.ts @@ -0,0 +1,110 @@ +import { + Body, + Controller, + Delete, + Get, + HttpException, + Param, + Post, + Put, + UseGuards +} from '@nestjs/common'; +import { SkillDto } from './skill.dto'; +import { SkillEntity } from './skill.entity'; +import { SkillService } from './skill.service'; +import { AuthGuard, CustomError, Public } from '@noahspan/noahspan-modules'; + +@Controller('skills') +export class SkillController { + constructor(private readonly skillService: SkillService) {} + + @Get(':id') + // @UseGuards(AuthGuard) + async find( + @Param('id') id: string, + ) { + try { + return await this.skillService.find(id); + } catch (error) { + const customError = error as CustomError; + + throw new HttpException(customError.message, customError.statusCode); + } + } + + @Get() + async findAll() { + try { + return await this.skillService.findAll(); + } catch (error) { + console.log(error) + const customError = error as CustomError; + + throw new HttpException(customError.message, customError.statusCode); + } + } + + @Post() + // @UseGuards(AuthGuard) + async create(@Body() skillDto: SkillDto) { + try { + console.log(skillDto) + return await this.skillService.create(skillDto); + } catch (error) { + const customError = error as CustomError; + console.log(customError) + throw new HttpException(customError.message, customError.statusCode); + } + } + + @Put(':id') + @UseGuards(AuthGuard) + async update( + @Param('id') id: string, + @Body() projectDto: SkillDto + ) { + try { + return await this.skillService.update(id, projectDto); + } catch (error) { + const customError = error as CustomError; + + throw new HttpException(customError.message, customError.statusCode); + } + } + + @Delete(':id') + @UseGuards(AuthGuard) + async delete( + @Param('id') id: string + ) { + try { + return await this.skillService.delete(id); + } catch (error) { + const customError = error as CustomError; + + throw new HttpException(customError.message, customError.statusCode); + } + } + + @Get('levels') + async levels() { + try { + return await this.skillService.getLevels(); + } catch (error) { + const customError = error as CustomError; + + throw new HttpException(customError.message, customError.statusCode) + } + } + + @Get('ranks') + async ranks() { + try { + return await this.skillService.getRanks(); + } catch (error) { + const customError = error as CustomError; + + throw new HttpException(customError.message, customError.statusCode) + } + } +} diff --git a/api/src/skill/skill.dto.ts b/api/src/skill/skill.dto.ts new file mode 100644 index 0000000..b0cd8e6 --- /dev/null +++ b/api/src/skill/skill.dto.ts @@ -0,0 +1,11 @@ +import { SkillLevelEntity } from "./skill-level.entity"; + +export class SkillDto { + categoryId: string; + name: string; + levelId: string; + rankId: string; + createdBy?: string; + updatedBy?: string; + deletedBy?: string; +} \ No newline at end of file diff --git a/api/src/skill/skill.entity.ts b/api/src/skill/skill.entity.ts new file mode 100644 index 0000000..2d722b3 --- /dev/null +++ b/api/src/skill/skill.entity.ts @@ -0,0 +1,52 @@ +import { SkillCategoryEntity } from 'src/skill/skill-category.entity'; +import { Entity, PrimaryGeneratedColumn, Column, JoinColumn, ManyToOne, ManyToMany, OneToOne, CreateDateColumn, UpdateDateColumn, DeleteDateColumn } from 'typeorm'; +import { SkillLevelEntity } from './skill-level.entity'; +import { SkillRankEntity } from './skill-rank.entity'; + +@Entity({ name: 'skills' }) +export class SkillEntity { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column() + name: string; + + @Column() + categoryId: string; + + @Column() + levelId: string + + @Column() + rankId: string; + + @CreateDateColumn() + created: Date; + + @Column({ nullable: true }) + createdBy: string; + + @UpdateDateColumn() + updated: Date; + + @Column({ nullable: true }) + updatedBy: string; + + @DeleteDateColumn() + deleted: Date; + + @Column({ nullable: true }) + deletedBy: string; + + @ManyToOne(() => SkillCategoryEntity, (category: SkillCategoryEntity) => category.name) + @JoinColumn({ name: 'categoryId' }) + category: SkillCategoryEntity; + + @OneToOne(() => SkillRankEntity, (rank: SkillRankEntity) => rank.name) + @JoinColumn({ name: 'rankId' }) + rank: SkillRankEntity; + + @ManyToOne(() => SkillLevelEntity, (level: SkillLevelEntity) => level.name) + @JoinColumn({ name: 'levelId' }) + level: SkillLevelEntity; +} \ No newline at end of file diff --git a/api/src/skill/skill.module.ts b/api/src/skill/skill.module.ts new file mode 100644 index 0000000..c00b550 --- /dev/null +++ b/api/src/skill/skill.module.ts @@ -0,0 +1,24 @@ +import { Module } from '@nestjs/common'; +import { SkillController } from './skill.controller'; +import { SkillService } from './skill.service'; +import { SkillEntity } from './skill.entity'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { SkillLevelEntity } from './skill-level.entity'; +import { SkillCategoryEntity } from './skill-category.entity'; +import { SkillCategoryController } from './skill-category.controller'; +import { SkillCategoryService } from './skill-category.service'; +import { SkillRankEntity } from './skill-rank.entity'; +import { SkillLevelController } from './skill-level.controller'; +import { SkillLevelService } from './skill-level.service'; +import { SkillRankController } from './skill-rank.controller'; +import { SkillRankService } from './skill-rank.service'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([SkillCategoryEntity, SkillEntity, SkillLevelEntity, SkillRankEntity]) + ], + controllers: [SkillCategoryController, SkillController, SkillLevelController, SkillRankController], + providers: [SkillCategoryService, SkillService, SkillLevelService, SkillRankService] +}) +export class SkillModule {} diff --git a/api/src/skill/skill.service.spec.ts b/api/src/skill/skill.service.spec.ts new file mode 100644 index 0000000..85f43a3 --- /dev/null +++ b/api/src/skill/skill.service.spec.ts @@ -0,0 +1,53 @@ +import { Test, TestingModule } from "@nestjs/testing"; +import { getRepositoryToken } from "@nestjs/typeorm"; +import { SkillService } from './skill.service'; +import { SkillEntity } from "./skill.entity"; + +describe('SkillService', () => { + let service: SkillService; + + const mockSkillRepository = { + delete: jest.fn(), + find: jest.fn(), + findOneBy: jest.fn(), + save: jest.fn() + } + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + SkillService, + { + provide: getRepositoryToken(SkillEntity), + useValue: mockSkillRepository + } + ] + }).compile(); + + service = module.get(SkillService); + }) + + it('should be defined', () => { + expect(service).toBeDefined(); + }) + + it('find => should find one skill by id', async () => { + + }) + + it('findAll => should fild all skills', async () => { + + }) + + it('create => should create a new skill', async () => { + + }) + + it('update => should update a skill', async () => { + + }) + + it('delete => should delete a skill', async () => { + + }) +}) \ No newline at end of file diff --git a/api/src/skill/skill.service.ts b/api/src/skill/skill.service.ts new file mode 100644 index 0000000..9aa30a5 --- /dev/null +++ b/api/src/skill/skill.service.ts @@ -0,0 +1,57 @@ +import { Injectable } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { SkillDto } from './skill.dto'; +import { SkillEntity } from './skill.entity'; +import { DeleteResult, Repository } from 'typeorm'; +import { SkillLevelEntity } from "./skill-level.entity"; +import { SkillRankEntity } from "./skill-rank.entity"; + +@Injectable() +export class SkillService { + constructor( + @InjectRepository(SkillEntity) private readonly skillRepository: Repository, + @InjectRepository(SkillLevelEntity) private readonly skillLevelRepository: Repository, + @InjectRepository(SkillRankEntity) private readonly skillRankRepository: Repository + ) {} + + async find(id: string): Promise { + return await this.skillRepository.findOne({ + where: { id: id }, + relations: ['category', 'rank', 'level'] + }) + } + + async findAll(): Promise { + return await this.skillRepository.find({ + relations: ['category', 'rank', 'level'] + }); + } + + async create(skill: SkillDto): Promise { + return await this.skillRepository.save(skill) + } + + async update( + id: string, + skillDto: SkillDto + ): Promise { + const skillEntity: SkillEntity = await this.skillRepository.findOneBy({ id }) + const skillEntityUpdated = Object.assign(skillEntity, skillDto) + + return await this.skillRepository.save(skillEntityUpdated) + } + + async delete(id: string): Promise { + return await this.skillRepository.delete({ id }) + } + + async getLevels(): Promise { + const levels = await this.skillLevelRepository.find() + console.log(levels); + return levels + } + + async getRanks(): Promise { + return await this.skillRankRepository.find() + } +} \ No newline at end of file diff --git a/api/src/status/status.entity.ts b/api/src/status/status.entity.ts new file mode 100644 index 0000000..c8a7c4e --- /dev/null +++ b/api/src/status/status.entity.ts @@ -0,0 +1,10 @@ +import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm'; + +@Entity({ name: 'statuses' }) +export class StatusEntity { + @PrimaryGeneratedColumn() + id: number; + + @Column() + name: string; +} \ No newline at end of file diff --git a/api/test/app.e2e-spec.ts b/api/test/app.e2e-spec.ts new file mode 100644 index 0000000..50cda62 --- /dev/null +++ b/api/test/app.e2e-spec.ts @@ -0,0 +1,24 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { INestApplication } from '@nestjs/common'; +import * as request from 'supertest'; +import { AppModule } from './../src/app.module'; + +describe('AppController (e2e)', () => { + let app: INestApplication; + + beforeEach(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = moduleFixture.createNestApplication(); + await app.init(); + }); + + it('/ (GET)', () => { + return request(app.getHttpServer()) + .get('/') + .expect(200) + .expect('Hello World!'); + }); +}); diff --git a/api/test/jest-e2e.json b/api/test/jest-e2e.json new file mode 100644 index 0000000..e9d912f --- /dev/null +++ b/api/test/jest-e2e.json @@ -0,0 +1,9 @@ +{ + "moduleFileExtensions": ["js", "json", "ts"], + "rootDir": ".", + "testEnvironment": "node", + "testRegex": ".e2e-spec.ts$", + "transform": { + "^.+\\.(t|j)s$": "ts-jest" + } +} diff --git a/api/tsconfig.build.json b/api/tsconfig.build.json new file mode 100644 index 0000000..64f86c6 --- /dev/null +++ b/api/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] +} diff --git a/api/tsconfig.json b/api/tsconfig.json new file mode 100644 index 0000000..95f5641 --- /dev/null +++ b/api/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "module": "commonjs", + "declaration": true, + "removeComments": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, + "target": "ES2021", + "sourceMap": true, + "outDir": "./dist", + "baseUrl": "./", + "incremental": true, + "skipLibCheck": true, + "strictNullChecks": false, + "noImplicitAny": false, + "strictBindCallApply": false, + "forceConsistentCasingInFileNames": false, + "noFallthroughCasesInSwitch": false + } +} diff --git a/client/.env b/client/.env new file mode 100644 index 0000000..ff7b713 --- /dev/null +++ b/client/.env @@ -0,0 +1,6 @@ +VITE_API_URL=http://localhost:3000 +# VITE_API_URL=https://flying-api-dev.greensea-e83e7646.centralus.azurecontainerapps.io/ +VITE_CLIENT_ID=b9beb879-aea1-4434-af04-7ab233021b48 +VITE_TENANT_ID=7fdd758c-7277-4d47-bf0a-0b8b164ef2a6 +VITE_REDIRECT_URL=http://localhost:8080 +VITE_ISSUER_URI="https://7fdd758c-7277-4d47-bf0a-0b8b164ef2a6.ciamlogin.com/7fdd758c-7277-4d47-bf0a-0b8b164ef2a6/v2.0/" \ No newline at end of file diff --git a/client/.gitignore b/client/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/client/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/client/README.md b/client/README.md new file mode 100644 index 0000000..fafd85d --- /dev/null +++ b/client/README.md @@ -0,0 +1 @@ +# APP \ No newline at end of file diff --git a/client/eslint.config.js b/client/eslint.config.js new file mode 100644 index 0000000..092408a --- /dev/null +++ b/client/eslint.config.js @@ -0,0 +1,28 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' + +export default tseslint.config( + { ignores: ['dist'] }, + { + extends: [js.configs.recommended, ...tseslint.configs.recommended], + files: ['**/*.{ts,tsx}'], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + plugins: { + 'react-hooks': reactHooks, + 'react-refresh': reactRefresh, + }, + rules: { + ...reactHooks.configs.recommended.rules, + 'react-refresh/only-export-components': [ + 'warn', + { allowConstantExport: true }, + ], + }, + }, +) diff --git a/client/index.html b/client/index.html new file mode 100644 index 0000000..77fc50f --- /dev/null +++ b/client/index.html @@ -0,0 +1,13 @@ + + + + + + + Noah Spannbauer + + +
+ + + diff --git a/client/package.json b/client/package.json new file mode 100644 index 0000000..ad0201d --- /dev/null +++ b/client/package.json @@ -0,0 +1,47 @@ +{ + "name": "app", + "private": true, + "version": "1.2.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "@fortawesome/fontawesome-svg-core": "^7.2.0", + "@fortawesome/free-solid-svg-icons": "^7.2.0", + "@fortawesome/react-fontawesome": "^3.3.1", + "@microsoft/microsoft-graph-types": "^2.40.0", + "@tailwindcss/typography": "^0.5.19", + "@tailwindcss/vite": "^4.3.0", + "@tanstack/react-table": "^8.21.3", + "axios": "^1.16.1", + "oidc-spa": "^10.2.3", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-hook-form": "^7.77.0", + "react-router-dom": "^7.1.5", + "tailwindcss": "^4.3.0", + "typeorm-extension": "^3.9.0", + "zod": "^4.4.3" + }, + "devDependencies": { + "@eslint/js": "^9.19.0", + "@types/react": "^19.0.8", + "@types/react-dom": "^19.0.3", + "@vitejs/plugin-react": "^4.3.4", + "daisyui": "^5.5.20", + "eslint": "^9.19.0", + "eslint-plugin-react-hooks": "^5.0.0", + "eslint-plugin-react-refresh": "^0.4.18", + "globals": "^15.14.0", + "typescript": "~5.7.2", + "typescript-eslint": "^8.22.0", + "vite": "^6.1.0" + }, + "files": [ + "dist" + ] +} diff --git a/client/public/noahspan-logo.png b/client/public/noahspan-logo.png new file mode 100644 index 0000000..c4cce0c Binary files /dev/null and b/client/public/noahspan-logo.png differ diff --git a/client/public/vite.svg b/client/public/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/client/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/client/src/App.tsx b/client/src/App.tsx new file mode 100644 index 0000000..f82890d --- /dev/null +++ b/client/src/App.tsx @@ -0,0 +1,23 @@ +import { Route, Routes } from 'react-router-dom'; +import Profiles from './components/profiles/Profiles'; +import Experiences from './components/experiences/Experiences'; +import Projects from './components/projects/Projects'; +import SiteNav from './components/siteNav/SiteNav'; +import SkillsPage from './components/skillsPage/SkillsPage'; + +const App = () => { + + return ( +
+ + + } /> + } /> + } /> + } /> + +
+ ) +} + +export default App; diff --git a/client/src/alert/Alert.interface.ts b/client/src/alert/Alert.interface.ts new file mode 100644 index 0000000..4ea37ae --- /dev/null +++ b/client/src/alert/Alert.interface.ts @@ -0,0 +1,7 @@ +export interface AlertProps { + children?: React.ReactNode; + className?: string; + closeIcon?: React.ReactNode; + onClose?: () => void; + severity: 'info' | 'error' | 'success' | 'warning'; +} \ No newline at end of file diff --git a/client/src/alert/Alert.tsx b/client/src/alert/Alert.tsx new file mode 100644 index 0000000..3404447 --- /dev/null +++ b/client/src/alert/Alert.tsx @@ -0,0 +1,44 @@ +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { AlertProps } from "./AlertProps.interface"; +import { faCircleCheck, faCircleInfo, faCircleXmark, faTriangleExclamation, faXmark } from "@fortawesome/free-solid-svg-icons"; + +const Alert = ({ + children, + className, + closeIcon, + severity, + onClose, + ...rest +}: AlertProps) => { + const severityVariants = { + info: 'alert-info', + error: 'alert-error', + success: 'alert-success', + warning: 'alert-warning' + }; + + return ( + <> +
+ + {severity === 'info' && } + {severity === 'error' && } + {severity === 'success' && ( + + )} + {severity === 'warning' && ( + + )} + + {children} + {closeIcon && } +
+ + ); +}; + +export default Alert; \ No newline at end of file diff --git a/client/src/assets/react.svg b/client/src/assets/react.svg new file mode 100644 index 0000000..6c87de9 --- /dev/null +++ b/client/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/client/src/components/SkillCategoryForm/SkillCategoryForm.tsx b/client/src/components/SkillCategoryForm/SkillCategoryForm.tsx new file mode 100644 index 0000000..b28fe9e --- /dev/null +++ b/client/src/components/SkillCategoryForm/SkillCategoryForm.tsx @@ -0,0 +1,159 @@ +import { useEffect, useReducer } from "react"; +import { SkillCategoryFormProps } from './SkillCategoryFormProps.interface' +import { Controller, FormProvider, useForm } from "react-hook-form"; +import { useBreakpoints } from "../../hooks/breakpoints/UseBreakpoints"; +import { FormMode } from "../../enums/formMode"; +import { initialState, reducer } from "./reducer"; +import httpClient from "../../httpClient/httpClient"; +import { AxiosError, AxiosResponse } from "axios"; +import { ScreenSize } from "../../enums/screenSize"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faSave, faXmark } from "@fortawesome/free-solid-svg-icons"; + +const SkillCategoryForm = ({ isDrawerOpen, mode, onOpenClose, categoryId }: SkillCategoryFormProps) => { + const [state, dispatch] = useReducer(reducer, initialState); + const defaultValues = { + name: '' + } + const methods = useForm({ + defaultValues: defaultValues + }) + const { screenSize } = useBreakpoints(); + + const onCancel = () => { + methods.reset(defaultValues); + dispatch({ type: 'SET_IS_DISABLED', payload: false }); + onOpenClose(FormMode.CANCEL) + } + + const onSubmit = async (data: unknown) => { + try { + dispatch({ type: 'SET_IS_LOADING', payload: true }) + + if (!categoryId) { + await httpClient.post(`api/skill-categories`, data); + } else { + await httpClient.put(`api/skill-categories`) + } + + methods.reset(defaultValues); + dispatch({ type: 'SET_IS_DISABLED', payload: false }) + onOpenClose(FormMode.CANCEL) + } catch (error) { + const axiosError = error as AxiosError; + + dispatch({ type: 'SET_ERROR', payload: axiosError.message }) + } finally { + dispatch({ type: 'SET_IS_LOADING', payload: false }) + } + } + + useEffect(() => { + if (mode === FormMode.VIEW) { + dispatch({ type: 'SET_IS_DISABLED', payload: true }) + } + }, [mode]) + + useEffect(() => { + const getSkillCategory = async () => { + try { + dispatch({ type: 'SET_IS_LOADING', payload: true }); + + const response: AxiosResponse = await httpClient.get( + `api/skill-categories/${categoryId}` + ); + const category = response.data + + methods.reset(category) + } catch (error) { + const axiosError = error as AxiosError; + + dispatch({ type: 'SET_ERROR', payload: axiosError.message }); + } finally { + dispatch({ type: 'SET_IS_LOADING', payload: false}) + } + } + + if (categoryId && isDrawerOpen) { + getSkillCategory() + } + }, [categoryId]) + + return ( +
+ {}} checked={isDrawerOpen} /> +
+ +
+ +
+
+
+

+ {`${mode.toString().toLowerCase().charAt(0).toUpperCase() + mode.toString().slice(1).toLowerCase()} Skill Category`} +

+
+
+ +
+
+ Name * +
+
+ ( + + )} + /> +
+
+ + {mode.toString() !== FormMode.VIEW && ( + + )} +
+
+
+
+
+
+
+ ) +}; + +export default SkillCategoryForm; \ No newline at end of file diff --git a/client/src/components/SkillCategoryForm/SkillCategoryFormProps.interface.ts b/client/src/components/SkillCategoryForm/SkillCategoryFormProps.interface.ts new file mode 100644 index 0000000..a5a4c8a --- /dev/null +++ b/client/src/components/SkillCategoryForm/SkillCategoryFormProps.interface.ts @@ -0,0 +1,8 @@ +import { FormMode } from "../../enums/formMode"; + +export interface SkillCategoryFormProps { + categoryId: string | undefined; + isDrawerOpen: boolean; + mode: FormMode; + onOpenClose: (mode: FormMode) => void; +} \ No newline at end of file diff --git a/client/src/components/SkillCategoryForm/SkillCategoryFormState.interface.ts b/client/src/components/SkillCategoryForm/SkillCategoryFormState.interface.ts new file mode 100644 index 0000000..1b74123 --- /dev/null +++ b/client/src/components/SkillCategoryForm/SkillCategoryFormState.interface.ts @@ -0,0 +1,5 @@ +export interface SkillCategoryFormState { + error: string | undefined; + isDisabled: boolean; + isLoading: boolean; +} \ No newline at end of file diff --git a/client/src/components/SkillCategoryForm/reducer.ts b/client/src/components/SkillCategoryForm/reducer.ts new file mode 100644 index 0000000..7ffdba7 --- /dev/null +++ b/client/src/components/SkillCategoryForm/reducer.ts @@ -0,0 +1,42 @@ +import { SkillFormState } from '../SkillForm/SkillFormState.interface'; +import { SkillCategoryFormState } from './SkillCategoryFormState.interface'; + +type Action = +| { type: 'SET_ERROR'; payload: string | undefined } +| { type: 'SET_IS_DISABLED'; payload: boolean } +| { type: 'SET_IS_LOADING'; payload: boolean } + +export const initialState: SkillCategoryFormState = { + error: undefined, + isDisabled: false, + isLoading: true +} + +export const reducer = ( + state: SkillCategoryFormState, + action: Action +): SkillCategoryFormState => { + switch (action.type) { + case 'SET_ERROR': { + return { + ...state, + error: action.payload + } + } + case 'SET_IS_DISABLED': { + return { + ...state, + isDisabled: action.payload + } + } + case 'SET_IS_LOADING': { + return { + ...state, + isLoading: action.payload + } + } + default: { + return state; + } + } +} \ No newline at end of file diff --git a/client/src/components/SkillForm/SkillForm.tsx b/client/src/components/SkillForm/SkillForm.tsx new file mode 100644 index 0000000..dfb23f5 --- /dev/null +++ b/client/src/components/SkillForm/SkillForm.tsx @@ -0,0 +1,297 @@ +import { useEffect, useReducer } from 'react'; +import { useForm, Controller, FormProvider } from 'react-hook-form'; +import { initialState, reducer } from './reducer'; +import { SkillFormProps } from './SkillFormProps.interface'; +import { FormMode } from '../../enums/formMode'; +import httpClient from '../../httpClient/httpClient'; +import { AxiosError, AxiosInstance, AxiosResponse } from 'axios'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faSave, faXmark } from '@fortawesome/free-solid-svg-icons'; +import { ScreenSize } from '../../enums/screenSize'; +import { useBreakpoints } from '../../hooks/breakpoints/UseBreakpoints'; + +const SkillForm = ({ isDrawerOpen, mode, onOpenClose, skillId }: SkillFormProps) => { + const [state, dispatch] = useReducer(reducer, initialState); + const defaultValues = { + name: '', + categoryId: '', + levelId: '', + rankId: '' + } + const methods = useForm({ + defaultValues: defaultValues + }); + const { screenSize } = useBreakpoints(); + + const onCancel = () => { + methods.reset(defaultValues); + dispatch({ type: 'SET_IS_DISABLED', payload: false }); + onOpenClose(FormMode.CANCEL); + }; + + const onSubmit = async (data: unknown) => { + try { + dispatch({ type: 'SET_IS_LOADING', payload: true }); + console.log(data) + if (!skillId) { + await httpClient.post(`api/skills`, data); + } else { + await httpClient.put(`api/skills/skill/${skillId}`, data); + } + + methods.reset(defaultValues); + dispatch({ type: 'SET_IS_DISABLED', payload: false }); + onOpenClose(FormMode.CANCEL); + } catch (error) { + const axiosError = error as AxiosError; + console.log(axiosError) + dispatch({ type: 'SET_ERROR', payload: axiosError.message }); + } finally { + dispatch({ type: 'SET_IS_LOADING', payload: false }); + } + }; + + useEffect(() => { + if (mode === FormMode.VIEW) { + dispatch({ type: 'SET_IS_DISABLED', payload: true }); + } + }, [mode]); + + useEffect(() => { + const getSkill = async () => { + try { + dispatch({ type: 'SET_IS_LOADING', payload: true }); + + const skillResponse: AxiosResponse = await httpClient.get( + `api/skills/${skillId}` + ); + const skill = skillResponse.data; + + methods.reset(skill); + } catch (error) { + const axiosError = error as AxiosError; + console.log(axiosError) + dispatch({ type: 'SET_ERROR', payload: axiosError.message }); + } finally { + dispatch({ type: 'SET_IS_LOADING', payload: false }); + } + }; + + if (skillId && isDrawerOpen) { + getSkill(); + } + }, [skillId]); + + useEffect(() => { + const getOptions = async () => { + try { + const skillCategoryResponse: AxiosResponse = await httpClient.get( + `api/skill-categories` + ) + const newSkillCategoryOptions = skillCategoryResponse.data.map((category: any) => { + return { + label: category.name, + value: category.id + } + }) + const skillLevelResponse: AxiosResponse = await httpClient.get( + `api/skill-levels` + ); + const newSkillLevelOptions = skillLevelResponse.data.map((level: any) => { + return { + label: level.name, + value: level.id + } + }); + const skillRankResponse: AxiosResponse = await httpClient.get( + `api/skill-ranks` + ); + const newSkillRankOptions = skillRankResponse.data.map((rank: any) => { + return { + label: rank.name, + value: rank.id + } + }); + + newSkillCategoryOptions.unshift({ + label: '', + value: '', + }) + + newSkillLevelOptions.unshift({ + label: '', + value: '', + }) + + newSkillRankOptions.unshift({ + label: '', + value: '', + }) + + dispatch({ type: 'SET_OPTIONS', payload: { categoryOptions: newSkillCategoryOptions, levelOptions: newSkillLevelOptions, rankOptions: newSkillRankOptions }}) + } catch (error) { + + } + } + + if (isDrawerOpen) { + getOptions() + } + }, [isDrawerOpen]) + + return ( +
+ {}} checked={isDrawerOpen} /> +
+ +
+ +
+
+
+

+ {`${mode.toString().toLowerCase().charAt(0).toUpperCase() + mode.toString().slice(1).toLowerCase()} Skill`} +

+
+
+ +
+
+ Name * +
+
+ ( + + )} + /> +
+
+ Cateogry +
+
+ { + return ( + + ) + }} + /> +
+
+ Level +
+
+ { + return ( + + ) + }} + /> +
+
+ Rank +
+
+ { + return ( + + ) + }} + /> +
+
+ + {mode.toString() !== FormMode.VIEW && ( + + )} +
+
+
+
+
+
+
+ ) +} + +export default SkillForm; \ No newline at end of file diff --git a/client/src/components/SkillForm/SkillFormProps.interface.ts b/client/src/components/SkillForm/SkillFormProps.interface.ts new file mode 100644 index 0000000..ef55c9c --- /dev/null +++ b/client/src/components/SkillForm/SkillFormProps.interface.ts @@ -0,0 +1,8 @@ +import { FormMode } from "../../enums/formMode"; + +export interface SkillFormProps { + skillId?: string; + isDrawerOpen: boolean; + mode: FormMode; + onOpenClose: (mode: FormMode) => void; +} \ No newline at end of file diff --git a/client/src/components/SkillForm/SkillFormState.interface.ts b/client/src/components/SkillForm/SkillFormState.interface.ts new file mode 100644 index 0000000..ee2d135 --- /dev/null +++ b/client/src/components/SkillForm/SkillFormState.interface.ts @@ -0,0 +1,8 @@ +export interface SkillFormState { + error: string | undefined; + isDisabled: boolean; + isLoading: boolean; + categoryOptions: { label: string; value: string }[]; + levelOptions: { label: string; value: string }[]; + rankOptions: { label: string; value: string }[]; +} \ No newline at end of file diff --git a/client/src/components/SkillForm/reducer.ts b/client/src/components/SkillForm/reducer.ts new file mode 100644 index 0000000..f1a2c08 --- /dev/null +++ b/client/src/components/SkillForm/reducer.ts @@ -0,0 +1,53 @@ +import { SkillFormState } from "./SkillFormState.interface" + +type Action = +| { type: 'SET_ERROR'; payload: string | undefined } +| { type: 'SET_IS_DISABLED'; payload: boolean } +| { type: 'SET_IS_LOADING'; payload: boolean } +| { type: 'SET_OPTIONS'; payload: { categoryOptions: { label: string, value: string; }[], levelOptions: { label: string, value: string; }[], rankOptions: { label: string, value: string; }[] }} + +export const initialState: SkillFormState = { + error: undefined, + isDisabled: false, + isLoading: true, + categoryOptions: [], + levelOptions: [], + rankOptions: [], +}; + +export const reducer = ( + state: SkillFormState, + action: Action +): SkillFormState => { + switch (action.type) { + case 'SET_ERROR': { + return { + ...state, + error: action.payload + }; + } + case 'SET_IS_DISABLED': { + return { + ...state, + isDisabled: action.payload + }; + } + case 'SET_IS_LOADING': { + return { + ...state, + isLoading: action.payload + }; + } + case 'SET_OPTIONS': { + return { + ...state, + categoryOptions: action.payload.categoryOptions, + levelOptions: action.payload.levelOptions, + rankOptions: action.payload.rankOptions + } + } + default: { + return state; + } + } +}; \ No newline at end of file diff --git a/client/src/components/actionMenu/ActionMenu.tsx b/client/src/components/actionMenu/ActionMenu.tsx new file mode 100644 index 0000000..353582c --- /dev/null +++ b/client/src/components/actionMenu/ActionMenu.tsx @@ -0,0 +1,62 @@ +import { useState } from 'react'; +import { ActionMenuProps } from './ActionMenuProps.interface'; +import { + Icon, + IconButton, + IconName, + ListItemIcon, + ListItemText, + Menu, + MenuItem, +} from '@noahspan/noahspan-components'; +import { FormMode } from '../../enums/formMode'; + +const ActionMenu = ({ id, onDelete, onOpenCloseForm }: ActionMenuProps) => { + const [anchorElAction, setAnchorElAction] = useState( + null + ); + + const onOpenActionMenu = (event: React.MouseEvent) => { + setAnchorElAction(event.currentTarget); + }; + + const onCloseActionMenu = () => { + setAnchorElAction(null); + }; + + return ( +
+ + + + + onOpenCloseForm(FormMode.EDIT, id)}> + + + + Edit + + onOpenCloseForm(FormMode.VIEW, id)}> + + + + View + +
+ onDelete(id)}> + + + + Delete + +
+
+ ); +}; + +export default ActionMenu; diff --git a/client/src/components/actionMenu/ActionMenuProps.interface.tsx b/client/src/components/actionMenu/ActionMenuProps.interface.tsx new file mode 100644 index 0000000..98309a4 --- /dev/null +++ b/client/src/components/actionMenu/ActionMenuProps.interface.tsx @@ -0,0 +1,7 @@ +import { FormMode } from '../../enums/formMode'; + +export interface ActionMenuProps { + id: string; + onDelete: (entryId: string) => void; + onOpenCloseForm: (formMode: FormMode, id: string) => void; +} diff --git a/client/src/components/confirmationDialog/ConfirmationDialog.tsx b/client/src/components/confirmationDialog/ConfirmationDialog.tsx new file mode 100644 index 0000000..3f8281f --- /dev/null +++ b/client/src/components/confirmationDialog/ConfirmationDialog.tsx @@ -0,0 +1,51 @@ +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + Icon, + IconName, + Spinner +} from '@noahspan/noahspan-components'; +import { ConfirmationDialogProps } from './ConfirmationDialogProps.interface'; + +const ConfirmationDialog = ({ + contentText, + isLoading, + isOpen, + onCancel, + onConfirm, + title +}: ConfirmationDialogProps) => { + return ( + + {title} + + {!isLoading && {contentText}} + {isLoading && } + + + + + + + ); +}; + +export default ConfirmationDialog; diff --git a/client/src/components/confirmationDialog/ConfirmationDialogProps.interface.ts b/client/src/components/confirmationDialog/ConfirmationDialogProps.interface.ts new file mode 100644 index 0000000..af258d3 --- /dev/null +++ b/client/src/components/confirmationDialog/ConfirmationDialogProps.interface.ts @@ -0,0 +1,8 @@ +export interface ConfirmationDialogProps { + contentText: string; + isLoading: boolean; + isOpen: boolean; + onCancel: () => void; + onConfirm: () => void; + title: string; +} diff --git a/client/src/components/experienceForm/ExperienceForm.tsx b/client/src/components/experienceForm/ExperienceForm.tsx new file mode 100644 index 0000000..c52bcb9 --- /dev/null +++ b/client/src/components/experienceForm/ExperienceForm.tsx @@ -0,0 +1,354 @@ +import { useEffect, useReducer } from 'react'; +import { useForm, Controller, FormProvider } from 'react-hook-form'; +import { initialState, reducer } from './reducer'; +import { ExperienceFormProps } from './ExperienceFormProps.interface'; +import { FormMode } from '../../enums/formMode'; +import httpClient from '../../httpClient/httpClient'; +import { AxiosError, AxiosInstance, AxiosResponse } from 'axios'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faSave, faXmark } from '@fortawesome/free-solid-svg-icons'; +import { ScreenSize } from '../../enums/screenSize'; +import { useBreakpoints } from '../../hooks/breakpoints/UseBreakpoints'; +import { Profile } from '../profiles/Profile.interface'; +import { useProfiles } from '../../hooks/profiles/UseProfiles'; +import { useAppContext } from '../../hooks/appContext/UseAppContext'; +import { Listbox, ListboxButton, ListboxOption, ListboxOptions } from '@headlessui/react'; +import { useSkills } from '../../hooks/skills/UseSkills'; + +const ExperienceForm = ({ isDrawerOpen, mode, onOpenClose, experienceId }: ExperienceFormProps) => { + const [state, dispatch] = useReducer(reducer, initialState); + const appContext = useAppContext(); + const { profiles } = useProfiles(); + const { skills } = useSkills(); + const defaultValues = { + profileId: '', + companyName: '', + companyGeneric: '', + title: '', + startDate: '', + endDate: '', + skills: [], + summary: '', + statusId: 1, + createdBy: '', + updatedBy: '' + } + const methods = useForm({ + defaultValues: defaultValues + }); + // const watchRepoName = methods.watch(['repoName']) + const { screenSize } = useBreakpoints(); + + const onCancel = () => { + methods.reset(defaultValues); + dispatch({ type: 'SET_IS_DISABLED', payload: false }); + onOpenClose(FormMode.CANCEL); + }; + + const onSubmit = async (data: any) => { + try { + dispatch({ type: 'SET_IS_LOADING', payload: true }); + + if (!experienceId) { + const newData = { + ...data, + createdBy: appContext.state.userProfile.userPrincipalName + } + await httpClient.post(`api/experiences`, newData); + } else { + const newData = { + ...data, + updatedBy: appContext.state.userProfile.userPrincipalName + } + + await httpClient.put(`api/experiences/${experienceId}`, newData); + } + + methods.reset(defaultValues); + dispatch({ type: 'SET_IS_DISABLED', payload: false }); + onOpenClose(FormMode.CANCEL); + } catch (error) { + const axiosError = error as AxiosError; + console.log(axiosError) + dispatch({ type: 'SET_ERROR', payload: axiosError.message }); + } finally { + dispatch({ type: 'SET_IS_LOADING', payload: false }); + } + }; + + useEffect(() => { + if (mode === FormMode.VIEW) { + dispatch({ type: 'SET_IS_DISABLED', payload: true }); + } + }, [mode]); + + useEffect(() => { + const getExperience = async () => { + try { + dispatch({ type: 'SET_IS_LOADING', payload: true }); + + const response: AxiosResponse = await httpClient.get( + `api/experiences/${experienceId}` + ); + const experience = response.data; + + methods.reset(experience); + } catch (error) { + const axiosError = error as AxiosError; + console.log(axiosError) + dispatch({ type: 'SET_ERROR', payload: axiosError.message }); + } finally { + dispatch({ type: 'SET_IS_LOADING', payload: false }); + } + }; + + if (experienceId && isDrawerOpen) { + getExperience(); + } + }, [experienceId]); + + useEffect(() => { + if (profiles && FormMode.ADD) { + const newProfileOptions = profiles + + newProfileOptions.unshift({ + id: '', + name: '', + headline: '', + summary: '', + userId: '', + experiences: [], + skills: [], + status: null + }) + + dispatch({ type: 'SET_PROFILE_OPTIONS', payload: newProfileOptions }); + } + }, [profiles]); + + useEffect(() => { + dispatch({ type: 'SET_SKILL_OPTIONS', payload: skills }) + }, [skills]) + + return ( +
+ {}} checked={isDrawerOpen} /> +
+ +
+ +
+
+
+

+ {`${mode.toString().toLowerCase().charAt(0).toUpperCase() + mode.toString().slice(1).toLowerCase()} Experience`} +

+
+
+ +
+
+ Profile +
+
+ { + return ( + + ); + }} + /> +
+
+ Company Name +
+
+ ( + + )} + /> +
+
+ Company Generic +
+
+ ( + + )} + /> +
+
+ Title +
+
+ ( + + )} + /> +
+
+ Start Date +
+
+ ( + + )} + /> +
+
+ End Date +
+
+ ( + + )} + /> +
+
+ Summary +
+
+ ( + + )} + /> +
+
+ Skills +
+
+ ( + // + + + {state.skillOptions?.map((skill) => ( + + {skill.name} + + ))} + + + )} + /> +
+
+ + {mode.toString() !== FormMode.VIEW && ( + + )} +
+
+
+
+
+
+
+ ) +} + +export default ExperienceForm; \ No newline at end of file diff --git a/client/src/components/experienceForm/ExperienceFormProps.interface.ts b/client/src/components/experienceForm/ExperienceFormProps.interface.ts new file mode 100644 index 0000000..f3b3052 --- /dev/null +++ b/client/src/components/experienceForm/ExperienceFormProps.interface.ts @@ -0,0 +1,8 @@ +import { FormMode } from "../../enums/formMode"; + +export interface ExperienceFormProps { + experienceId?: string; + isDrawerOpen: boolean; + mode: FormMode; + onOpenClose: (mode: FormMode) => void; +} \ No newline at end of file diff --git a/client/src/components/experienceForm/ExperienceFormState.interface.ts b/client/src/components/experienceForm/ExperienceFormState.interface.ts new file mode 100644 index 0000000..4868aed --- /dev/null +++ b/client/src/components/experienceForm/ExperienceFormState.interface.ts @@ -0,0 +1,10 @@ +import { Profile } from "../profiles/Profile.interface"; +import { Skill } from "../skills/Skill.interface"; + +export interface ExperienceFormState { + error: string | undefined; + isDisabled: boolean; + isLoading: boolean; + profileOptions: Profile[]; + skillOptions: Skill[] | undefined; +} \ No newline at end of file diff --git a/client/src/components/experienceForm/reducer.ts b/client/src/components/experienceForm/reducer.ts new file mode 100644 index 0000000..4a90b83 --- /dev/null +++ b/client/src/components/experienceForm/reducer.ts @@ -0,0 +1,59 @@ +import { Profile } from "../profiles/Profile.interface"; +import { Skill } from "../skills/Skill.interface"; +import { ExperienceFormState } from "./ExperienceFormState.interface" + +type Action = +| { type: 'SET_ERROR'; payload: string | undefined } +| { type: 'SET_IS_DISABLED'; payload: boolean } +| { type: 'SET_IS_LOADING'; payload: boolean } +| { type: 'SET_PROFILE_OPTIONS'; payload: Profile[] } +| { type: 'SET_SKILL_OPTIONS'; payload: Skill[] | undefined }; + +export const initialState: ExperienceFormState = { + error: undefined, + isDisabled: false, + isLoading: true, + profileOptions: [], + skillOptions: [] +}; + +export const reducer = ( + state: ExperienceFormState, + action: Action +): ExperienceFormState => { + switch (action.type) { + case 'SET_ERROR': { + return { + ...state, + error: action.payload + }; + } + case 'SET_IS_DISABLED': { + return { + ...state, + isDisabled: action.payload + }; + } + case 'SET_IS_LOADING': { + return { + ...state, + isLoading: action.payload + }; + } + case 'SET_PROFILE_OPTIONS': { + return { + ...state, + profileOptions: action.payload + } + } + case 'SET_SKILL_OPTIONS': { + return { + ...state, + skillOptions: action.payload + } + } + default: { + return state; + } + } +}; \ No newline at end of file diff --git a/client/src/components/experiences/Experience.interface.ts b/client/src/components/experiences/Experience.interface.ts new file mode 100644 index 0000000..2dff29f --- /dev/null +++ b/client/src/components/experiences/Experience.interface.ts @@ -0,0 +1,18 @@ +import { Status } from '../../interfaces/Status.interface'; +import { Profile } from '../profiles/Profile.interface'; +import { Skill } from "../skills/Skill.interface"; + +export interface Experience { + id: string; + profileId: string; + companyName: string; + companyGenericName: string; + location: string; + title: string; + startDate: Date; + endDate?: Date; + summary?: string; + skills: Skill[]; + profile: Profile; + status: Status; +} \ No newline at end of file diff --git a/client/src/components/experiences/Experiences.tsx b/client/src/components/experiences/Experiences.tsx new file mode 100644 index 0000000..c87477a --- /dev/null +++ b/client/src/components/experiences/Experiences.tsx @@ -0,0 +1,370 @@ +import { Experience } from './Experience.interface'; +import { FormMode } from '../../enums/formMode'; +import { useEffect, useReducer } from 'react'; +import { initialState, reducer } from './reducer'; +import { AxiosError, AxiosInstance, AxiosResponse } from 'axios'; +import httpClient from '../../httpClient/httpClient' +import { useOidc } from '../../oidc'; +import { useBreakpoints } from "../../hooks/breakpoints/UseBreakpoints"; +import ExperienceForm from '../experienceForm/ExperienceForm'; +import ConfirmationDialog from '../confirmationDialog/ConfirmationDialog'; +import ActionMenu from '../actionMenu/ActionMenu'; +import { ScreenSize } from '../../enums/screenSize'; +import { faEllipsisVertical, faEye, faPen, faPlane, faPlus, faTrash } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { UserRole } from '../../enums/userRole'; +import { useUserRole } from '../../hooks/userRole/UseUserRole'; +import Alert from '../../alert/Alert'; +import { CellContext, ColumnDef, flexRender, getCoreRowModel, getPaginationRowModel, useReactTable } from '@tanstack/react-table'; +import { Profile } from '../profiles/Profile.interface'; + +interface ActionsProps { + id: string; +} + +const Experiences = () => { + const [state, dispatch] = useReducer(reducer, initialState) + const { userRole } = useUserRole(); + const { screenSize } = useBreakpoints(); + const { isUserLoggedIn } = useOidc(); + const Actions = ({ id }: ActionsProps) => { + return ( +
+
+ +
+ ) + } + const getExperiences = async (): Promise => { + try { + const response: AxiosResponse = await httpClient.get( + `api/experiences`, + ); + const experiences: Experience[] = response.data; + console.log(experiences) + + return experiences; + } catch (error) { + const axiosError = error as AxiosError; + + throw new Error(axiosError.message); + } + }; + + const onOpenCloseExperienceForm = (mode: FormMode, experienceId?: string) => { + switch (mode) { + case FormMode.ADD: + case FormMode.EDIT: + case FormMode.VIEW: + dispatch({ + type: 'SET_OPEN_CLOSE_EXPERIENCE_FORM', + payload: { + formMode: mode, + selectedExperienceId: experienceId, + isFormOpen: true + } + }); + + break; + case FormMode.CANCEL: + dispatch({ + type: 'SET_OPEN_CLOSE_EXPERIENCE_FORM', + payload: { + formMode: mode, + selectedExperienceId: undefined, + isFormOpen: false + } + }); + + break; + } + }; + + const onDeleteExperience = (entryId: string) => { + dispatch({ + type: 'SET_DELETE', + payload: { isConfirmationDialogOpen: true, selectedExperienceId: entryId } + }); + }; + + const onConfirmationDialogConfirm = async () => { + try { + dispatch({ type: 'SET_IS_CONFIRMATION_DIALOG_LOADING', payload: true }); + + await httpClient.delete(`api/experiences/${state.selectedExperienceId}`); + + dispatch({ + type: 'SET_DELETE', + payload: { isConfirmationDialogOpen: false, selectedExperienceId: undefined } + }); + await getExperiences(); + } catch (error) { + const axiosError = error as AxiosError; + + dispatch({ + type: 'SET_ALERT', + payload: { severity: 'error', message: `Loading of experiences failed with the following message: ${axiosError.message}` } + }); + } finally { + dispatch({ type: 'SET_IS_CONFIRMATION_DIALOG_LOADING', payload: false }); + } + }; + + const onConfirmationDialogCancel = () => { + dispatch({ + type: 'SET_DELETE', + payload: { isConfirmationDialogOpen: false, selectedExperienceId: undefined } + }); + }; + + const columns: ColumnDef[]= [ + { + id: 'profile', + accessorKey: 'profile', + header: 'Profile', + cell: (info: CellContext) => { + const profile: any = info.getValue(); + + return profile.name + } + }, + { + id: 'companyName', + accessorKey: 'companyName', + header: 'Company Name', + }, + { + id: 'companyGeneric', + accessorKey: 'companyGeneric', + header: 'Company Generic' + }, + { + id: 'title', + accessorKey: 'title', + header: 'Title' + }, + { + id: 'startDate', + accessorKey: 'startDate', + header: 'Start Date' + }, + { + id: 'endDate', + accessorKey: 'endDate', + header: 'End Date' + }, + { + id: 'summary', + accessorKey: 'summary', + header: 'Summary' + }, + { + id: 'status', + accessorKey: 'status', + header: 'Status', + cell: (info: CellContext) => { + console.log(info.getValue()) + const status: any = info.getValue(); + console.log(status) + return status.name + } + }, + { + id: 'actions', + header: 'Actions', + meta: { + align: 'text-center', + headerAlign: 'text-center' + }, + cell: (info: CellContext) => { + return ( + + ) + } + } + ]; + + const table = useReactTable({ + data: state.experiences, + columns: columns, + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel() + }); + + useEffect(() => { + const loadExperiences = async () => { + try { + dispatch({ type: 'SET_IS_LOADING', payload: true }) + const experiences = await getExperiences(); + + if (experiences.length > 0) { + dispatch({ type: 'SET_EXPERIENCES', payload: experiences }); + + if (state.alert) { + dispatch({ type: 'SET_ALERT', payload: undefined }) + } + } else { + dispatch({ type: 'SET_ALERT', payload: { severity: 'info', message: 'No experiences found.'}}); + } + } catch (error) { + dispatch({ + type: 'SET_ALERT', + payload: { severity: 'error', message: `Loading of experiences failed with the following message: ${error}` } + }); + } finally { + dispatch({ type: 'SET_IS_LOADING', payload: false }) + } + } + + if (!state.isFormOpen || !state.isConfirmDialogOpen) { + loadExperiences(); + } + }, [state.isFormOpen, state.isConfirmDialogOpen]); + + return ( + <> +
+
+
+

Experiences

+
+
+ {!state.isLoading && userRole === UserRole.WRITE && + + } +
+ {!state.isLoading && state.alert && ( +
+ + dispatch({ type: 'SET_ALERT', payload: undefined }) + } + severity={state.alert.severity} + > + {state.alert.message} + +
+ )} + {state.experiences.length > 0 && screenSize !== ScreenSize.SM && +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + ); + })} + + ))} + + + <> + {table.getRowModel().rows.map((row) => { + return ( + + {row.getVisibleCells().map((cell) => { + return ( + + ); + })} + + ); + })} + + +
+ {header.isPlaceholder ? null : ( +
+ {flexRender( + header.column.columnDef.header, + header.getContext() + )} + {/* {header.column.getCanFilter() ? ( +
+ +
+ ) : null} */} +
+ )} +
*]:z-1 [&>*]:relative outline-solid outline-transparent data-[focus-visible=true]:z-10 data-[focus-visible=true]:outline-2 data-[focus-visible=true]:outline-focus data-[focus-visible=true]:outline-offset-2 before:pointer-events-none before:content-[''] before:absolute before:z-0 before:inset-0 before:opacity-0 data-[selected=true]:before:opacity-100 group-data-[disabled=true]/tr:text-foreground-300 group-data-[disabled=true]/tr:cursor-not-allowed before:bg-default/60 data-[selected=true]:text-default-foreground first:before:rounded-s-lg last:before:rounded-e-lg text-start`} + key={cell.id} + > + {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} +
+
+ } + {state.experiences.length > 0 && screenSize === ScreenSize.SM && +
+ <> + {table.getRowModel().rows.map((row) => { + return ( +
+
+
+ <> + {row.getVisibleCells().map((cell) => { + return ( + <> + {cell.column.columnDef.header !== 'Actions' && + <> +
+ {flexRender(cell.column.columnDef.cell, cell.getContext())} +
+
+ +
+ + } + + ) + })} + +
+
+
+ ) + })} + +
+ } +
+
+ {state.isFormOpen && + onOpenCloseExperienceForm(mode)} + experienceId={state.selectedExperienceId} + /> + } + + ) +} + +export default Experiences; \ No newline at end of file diff --git a/client/src/components/experiences/ExperiencesState.interface.ts b/client/src/components/experiences/ExperiencesState.interface.ts new file mode 100644 index 0000000..9608b22 --- /dev/null +++ b/client/src/components/experiences/ExperiencesState.interface.ts @@ -0,0 +1,14 @@ +import { FormMode } from '../../enums/formMode'; +import { Alert } from '../../interfaces'; +import { Experience } from './Experience.interface'; + +export interface ExperiencesState { + alert: Alert | undefined; + experiences: Experience[]; + formMode: FormMode; + isConfirmDialogLoading: boolean; + isConfirmDialogOpen: boolean; + isFormOpen: boolean; + isLoading: boolean; + selectedExperienceId: string | undefined; +} \ No newline at end of file diff --git a/client/src/components/experiences/reducer.ts b/client/src/components/experiences/reducer.ts new file mode 100644 index 0000000..00d2d55 --- /dev/null +++ b/client/src/components/experiences/reducer.ts @@ -0,0 +1,93 @@ +import { FormMode } from '../../enums/formMode'; +import { ExperiencesState } from './ExperiencesState.interface' +import { Experience } from './Experience.interface'; +import { Alert } from '../../interfaces'; + +type Action = + | { + type: 'SET_DELETE'; + payload: { + isConfirmationDialogOpen: boolean; + selectedExperienceId: string | undefined; + }; + } + | { type: 'SET_EXPERIENCES'; payload: Experience[] } + | { type: 'SET_ALERT'; payload: Alert | undefined } + | { type: 'SET_FORM_MODE'; payload: FormMode } + | { type: 'SET_IS_CONFIRMATION_DIALOG_LOADING'; payload: boolean } + | { type: 'SET_IS_LOADING'; payload: boolean } + | { + type: 'SET_OPEN_CLOSE_EXPERIENCE_FORM'; + payload: { + formMode: FormMode; + selectedExperienceId: string | undefined; + isFormOpen: boolean; + }; + }; + +export const initialState: ExperiencesState = { + alert: undefined, + formMode: FormMode.CANCEL, + isConfirmDialogLoading: false, + isConfirmDialogOpen: false, + isFormOpen: false, + isLoading: false, + experiences: [], + selectedExperienceId: undefined +}; + +export const reducer = ( + state: ExperiencesState, + action: Action +): ExperiencesState => { + switch (action.type) { + case 'SET_DELETE': { + return { + ...state, + isConfirmDialogOpen: action.payload.isConfirmationDialogOpen, + selectedExperienceId: action.payload.selectedExperienceId + }; + } + case 'SET_EXPERIENCES': { + return { + ...state, + experiences: action.payload + }; + } + case 'SET_ALERT': { + return { + ...state, + alert: action.payload + }; + } + case 'SET_FORM_MODE': { + return { + ...state, + formMode: action.payload + }; + } + case 'SET_IS_CONFIRMATION_DIALOG_LOADING': { + return { + ...state, + isConfirmDialogLoading: action.payload + }; + } + case 'SET_IS_LOADING': { + return { + ...state, + isLoading: action.payload + }; + } + case 'SET_OPEN_CLOSE_EXPERIENCE_FORM': { + return { + ...state, + formMode: action.payload.formMode, + isFormOpen: action.payload.isFormOpen, + selectedExperienceId: action.payload.selectedExperienceId + }; + } + default: { + return state; + } + } +}; diff --git a/client/src/components/multiSelectDropdown/MultiSelectDropdown.tsx b/client/src/components/multiSelectDropdown/MultiSelectDropdown.tsx new file mode 100644 index 0000000..195ea22 --- /dev/null +++ b/client/src/components/multiSelectDropdown/MultiSelectDropdown.tsx @@ -0,0 +1,97 @@ +import { useEffect, useRef, useState } from "react"; +import { MultiSelectDropdownProps } from "./MultiSelectDropdownProps"; + +const MultiSelectDropdown = ({ options }: MultiSelectDropdownProps) => { + const [isOpen, setIsOpen] = useState(false); + const [selectedValues, setSelectedValues] = useState([]) + const dropdownRef = useRef(null) + + const handleToggleOption = (value: any) => { + let updated; + + if (selectedValues.includes(value)) { + updated = selectedValues.filter((item) => item !== value); + } else { + updated = [...selectedValues, value]; + } + + setSelectedValues(updated); + if (onChange) onChange(updated) + } + + const handleRemoveBadge = (event, value) => { + event.stopPropagation(); + + const updated = selectedValues.filter((item) => item !== value); + + setSelectedValues(updated) + + if (onChange) onchange(updated) + } + + useEffect(() => { + const handleOutsideClick = (event: any) => { + if (dropdownRef.current && !dropdownRef.current.contains(event.target)) { + setIsOpen(false); + } + } + + document.addEventListener('mousedown', handleOutsideClick); + + return () => document.removeEventListener('mousedown', handleOutsideClick) + }, []) + + return ( +
+
+
setIsOpen(!isOpen)} + > + {selectedValues.length === 0 ? ( + + ) : ( +
+ {selectedValues.map((val) => { + const option = options.find((o: any) => o.value === val); + return ( +
+ {option?.label || val} + +
+ ); + })} +
+ )} +
+
    + {options.map((option) => ( +
  • + +
  • + ))} +
+
+
+ ); +} + +export default MultiSelectDropdown; \ No newline at end of file diff --git a/client/src/components/multiSelectDropdown/MultiSelectDropdownProps.ts b/client/src/components/multiSelectDropdown/MultiSelectDropdownProps.ts new file mode 100644 index 0000000..d52c1f3 --- /dev/null +++ b/client/src/components/multiSelectDropdown/MultiSelectDropdownProps.ts @@ -0,0 +1,3 @@ +export interface MultiSelectDropdownProps { + options: { label: string; value: string }[]; +} \ No newline at end of file diff --git a/client/src/components/profileForm/ProfileForm.tsx b/client/src/components/profileForm/ProfileForm.tsx new file mode 100644 index 0000000..6b59bde --- /dev/null +++ b/client/src/components/profileForm/ProfileForm.tsx @@ -0,0 +1,213 @@ +import { useEffect, useReducer } from 'react'; +import { useForm, Controller, FormProvider } from 'react-hook-form'; +import { initialState, reducer } from './reducer'; +import { ProfileFormProps } from './ProfileFormProps.interface'; +import { FormMode } from '../../enums/formMode'; +import httpClient from '../../httpClient/httpClient'; +import { AxiosError, AxiosInstance, AxiosResponse } from 'axios'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faSave, faXmark } from '@fortawesome/free-solid-svg-icons'; +import { ScreenSize } from '../../enums/screenSize'; +import { useBreakpoints } from '../../hooks/breakpoints/UseBreakpoints'; +import { useAppContext } from '../../hooks/appContext/UseAppContext'; + +const ProfileForm = ({ isDrawerOpen, mode, onOpenClose, profileId }: ProfileFormProps) => { + const [state, dispatch] = useReducer(reducer, initialState); + const appContext = useAppContext(); + const defaultValues = { + name: '', + headline: '', + summary: '', + userId: '', + statusId: 1 + } + const methods = useForm({ + defaultValues: defaultValues + }); + // const watchRepoName = methods.watch(['repoName']) + const { screenSize } = useBreakpoints(); + + const onCancel = () => { + methods.reset(defaultValues); + dispatch({ type: 'SET_IS_DISABLED', payload: false }); + onOpenClose(FormMode.CANCEL); + }; + + const onSubmit = async (data: any) => { + try { + dispatch({ type: 'SET_IS_LOADING', payload: true }); + console.log(data) + if (!profileId) { + const newData = { + ...data, + createdBy: appContext.state.userProfile.userPrincipalName, + userId: appContext.state.userProfile.userPrincipalName + } + console.log(newData) + await httpClient.post(`api/profiles`, newData); + } else { + const newData = { + ...data, + updatedBy: appContext.state.userProfile.userPrincipalName + } + console.log(newData) + await httpClient.put(`api/profiles/${profileId}`, newData); + } + + methods.reset(defaultValues); + dispatch({ type: 'SET_IS_DISABLED', payload: false }); + onOpenClose(FormMode.CANCEL); + } catch (error) { + const axiosError = error as AxiosError; + console.log(axiosError) + dispatch({ type: 'SET_ERROR', payload: axiosError.message }); + } finally { + dispatch({ type: 'SET_IS_LOADING', payload: false }); + } + }; + + useEffect(() => { + if (mode === FormMode.VIEW) { + dispatch({ type: 'SET_IS_DISABLED', payload: true }); + } + }, [mode]); + + useEffect(() => { + const getProfile = async () => { + try { + dispatch({ type: 'SET_IS_LOADING', payload: true }); + + const response: AxiosResponse = await httpClient.get( + `api/profiles/${profileId}` + ); + const entry = response.data; + + methods.reset(entry); + } catch (error) { + const axiosError = error as AxiosError; + console.log(axiosError) + dispatch({ type: 'SET_ERROR', payload: axiosError.message }); + } finally { + dispatch({ type: 'SET_IS_LOADING', payload: false }); + } + }; + + if (profileId && isDrawerOpen) { + getProfile(); + } + }, [profileId]); + + return ( +
+ {}} checked={isDrawerOpen} /> +
+ +
+ +
+
+
+

+ {`${mode.toString().toLowerCase().charAt(0).toUpperCase() + mode.toString().slice(1).toLowerCase()} Profile`} +

+
+
+ +
+
+ Name * +
+
+ ( + + )} + /> +
+
+ Headline +
+
+ ( + + )} + /> +
+
+ Summary +
+
+ ( + + )} + /> +
+
+ + {mode.toString() !== FormMode.VIEW && ( + + )} +
+
+
+
+
+
+
+ ) +} + +export default ProfileForm; \ No newline at end of file diff --git a/client/src/components/profileForm/ProfileFormProps.interface.ts b/client/src/components/profileForm/ProfileFormProps.interface.ts new file mode 100644 index 0000000..54e45b2 --- /dev/null +++ b/client/src/components/profileForm/ProfileFormProps.interface.ts @@ -0,0 +1,8 @@ +import { FormMode } from "../../enums/formMode"; + +export interface ProfileFormProps { + profileId?: string; + isDrawerOpen: boolean; + mode: FormMode; + onOpenClose: (mode: FormMode) => void; +} \ No newline at end of file diff --git a/client/src/components/profileForm/ProfileFormState.interface.ts b/client/src/components/profileForm/ProfileFormState.interface.ts new file mode 100644 index 0000000..d7d15dc --- /dev/null +++ b/client/src/components/profileForm/ProfileFormState.interface.ts @@ -0,0 +1,5 @@ +export interface ProfileFormState { + error: string | undefined; + isDisabled: boolean; + isLoading: boolean; +} \ No newline at end of file diff --git a/client/src/components/profileForm/reducer.ts b/client/src/components/profileForm/reducer.ts new file mode 100644 index 0000000..5a5c875 --- /dev/null +++ b/client/src/components/profileForm/reducer.ts @@ -0,0 +1,42 @@ +import { Profile } from "../profiles/Profile.interface"; +import { ProfileFormState } from "./ProfileFormState.interface" + +type Action = +| { type: 'SET_ERROR'; payload: string | undefined } +| { type: 'SET_IS_DISABLED'; payload: boolean } +| { type: 'SET_IS_LOADING'; payload: boolean }; + +export const initialState: ProfileFormState = { + error: undefined, + isDisabled: false, + isLoading: true +}; + +export const reducer = ( + state: ProfileFormState, + action: Action +): ProfileFormState => { + switch (action.type) { + case 'SET_ERROR': { + return { + ...state, + error: action.payload + }; + } + case 'SET_IS_DISABLED': { + return { + ...state, + isDisabled: action.payload + }; + } + case 'SET_IS_LOADING': { + return { + ...state, + isLoading: action.payload + }; + } + default: { + return state; + } + } +}; \ No newline at end of file diff --git a/client/src/components/profiles/Profile.interface.ts b/client/src/components/profiles/Profile.interface.ts new file mode 100644 index 0000000..8f1ec6c --- /dev/null +++ b/client/src/components/profiles/Profile.interface.ts @@ -0,0 +1,14 @@ +import { Status } from "../../interfaces/Status.interface"; +import { Experience } from "../experiences/Experience.interface"; +import { Skill } from "../skills/Skill.interface"; + +export interface Profile { + id: string; + name: string; + experiences: Experience[]; + headline: string; + skills: Skill[]; + status: Status | null; + summary: string; + userId: string; +} \ No newline at end of file diff --git a/client/src/components/profiles/Profiles.tsx b/client/src/components/profiles/Profiles.tsx new file mode 100644 index 0000000..b092675 --- /dev/null +++ b/client/src/components/profiles/Profiles.tsx @@ -0,0 +1,348 @@ +import { Profile } from './Profile.interface'; +import { FormMode } from '../../enums/formMode'; +import { useEffect, useReducer } from 'react'; +import { initialState, reducer } from './reducer'; +import { AxiosError, AxiosInstance, AxiosResponse } from 'axios'; +import httpClient from '../../httpClient/httpClient' +import { useOidc } from '../../oidc'; +import { useBreakpoints } from "../../hooks/breakpoints/UseBreakpoints"; +import ProfileForm from '../profileForm/ProfileForm'; +import ConfirmationDialog from '../confirmationDialog/ConfirmationDialog'; +import ActionMenu from '../actionMenu/ActionMenu'; +import { ScreenSize } from '../../enums/screenSize'; +import { faEllipsisVertical, faEye, faPen, faPlane, faPlus, faTrash } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { UserRole } from '../../enums/userRole'; +import { useUserRole } from '../../hooks/userRole/UseUserRole'; +import Alert from '../../alert/Alert'; +import { CellContext, ColumnDef, flexRender, getCoreRowModel, getPaginationRowModel, useReactTable } from '@tanstack/react-table'; + +interface ActionsProps { + id: string; +} + +const Profiles = () => { + const [state, dispatch] = useReducer(reducer, initialState) + const { userRole } = useUserRole(); + const { screenSize } = useBreakpoints(); + const { isUserLoggedIn } = useOidc(); + const Actions = ({ id }: ActionsProps) => { + return ( +
+
+ +
+ ) + } + const getProfiles = async (): Promise => { + try { + const response: AxiosResponse = await httpClient.get( + `api/profiles`, + ); + const profiles: Profile[] = response.data; + console.log(profiles) + + return profiles; + + return [] + } catch (error) { + const axiosError = error as AxiosError; + + throw new Error(axiosError.message); + } + }; + + const onOpenCloseProfileForm = (mode: FormMode, profileId?: string) => { + switch (mode) { + case FormMode.ADD: + case FormMode.EDIT: + case FormMode.VIEW: + dispatch({ + type: 'SET_OPEN_CLOSE_PROFILE_FORM', + payload: { + formMode: mode, + selectedProfileId: profileId, + isFormOpen: true + } + }); + + break; + case FormMode.CANCEL: + dispatch({ + type: 'SET_OPEN_CLOSE_PROFILE_FORM', + payload: { + formMode: mode, + selectedProfileId: undefined, + isFormOpen: false + } + }); + + break; + } + }; + + const onDeleteProfile = (entryId: string) => { + dispatch({ + type: 'SET_DELETE', + payload: { isConfirmationDialogOpen: true, selectedProfileId: entryId } + }); + }; + + const onConfirmationDialogConfirm = async () => { + try { + dispatch({ type: 'SET_IS_CONFIRMATION_DIALOG_LOADING', payload: true }); + + await httpClient.delete(`api/profiles/profile/${state.selectedProfileId}`); + + dispatch({ + type: 'SET_DELETE', + payload: { isConfirmationDialogOpen: false, selectedProfileId: undefined } + }); + await getProfiles(); + } catch (error) { + const axiosError = error as AxiosError; + + dispatch({ + type: 'SET_ALERT', + payload: { severity: 'error', message: `Loading of profiles failed with the following message: ${axiosError.message}` } + }); + } finally { + dispatch({ type: 'SET_IS_CONFIRMATION_DIALOG_LOADING', payload: false }); + } + }; + + const onConfirmationDialogCancel = () => { + dispatch({ + type: 'SET_DELETE', + payload: { isConfirmationDialogOpen: false, selectedProfileId: undefined } + }); + }; + + const columns: ColumnDef[]= [ + { + id: 'name', + accessorKey: 'name', + header: 'Name' + }, + { + id: 'headline', + accessorKey: 'headline', + header: 'Headline', + }, + { + id: 'summary', + accessorKey: 'summary', + header: 'Summary' + }, + { + id: 'status', + accessorKey: 'status', + header: 'Status', + cell: (info: CellContext) => { + console.log(info.getValue()) + const status: any = info.getValue(); + console.log(status) + return status.name + } + }, + { + id: 'actions', + header: 'Actions', + meta: { + align: 'text-center', + headerAlign: 'text-center' + }, + cell: (info: CellContext) => { + return ( + + ) + } + } + ]; + + const table = useReactTable({ + data: state.profiles, + columns: columns, + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel() + }); + + useEffect(() => { + const loadProfiles = async () => { + try { + dispatch({ type: 'SET_IS_LOADING', payload: true }) + const profiles = await getProfiles(); + + if (profiles.length > 0) { + dispatch({ type: 'SET_PROFILES', payload: profiles }); + + if (state.alert) { + dispatch({ type: 'SET_ALERT', payload: undefined }) + } + } else { + dispatch({ type: 'SET_ALERT', payload: { severity: 'info', message: 'No profiles found.'}}); + } + } catch (error) { + dispatch({ + type: 'SET_ALERT', + payload: { severity: 'error', message: `Loading of profiles failed with the following message: ${error}` } + }); + } finally { + dispatch({ type: 'SET_IS_LOADING', payload: false }) + } + } + + if (!state.isFormOpen || !state.isConfirmDialogOpen) { + loadProfiles(); + } + }, [state.isFormOpen, state.isConfirmDialogOpen]); + + return ( + <> +
+
+
+

Profiles

+
+
+ {!state.isLoading && userRole === UserRole.WRITE && + + } +
+ {!state.isLoading && state.alert && ( +
+ + dispatch({ type: 'SET_ALERT', payload: undefined }) + } + severity={state.alert.severity} + > + {state.alert.message} + +
+ )} + {state.profiles.length > 0 && screenSize !== ScreenSize.SM && +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + ); + })} + + ))} + + + <> + {table.getRowModel().rows.map((row) => { + return ( + + {row.getVisibleCells().map((cell) => { + return ( + + ); + })} + + ); + })} + + +
+ {header.isPlaceholder ? null : ( +
+ {flexRender( + header.column.columnDef.header, + header.getContext() + )} + {/* {header.column.getCanFilter() ? ( +
+ +
+ ) : null} */} +
+ )} +
*]:z-1 [&>*]:relative outline-solid outline-transparent data-[focus-visible=true]:z-10 data-[focus-visible=true]:outline-2 data-[focus-visible=true]:outline-focus data-[focus-visible=true]:outline-offset-2 before:pointer-events-none before:content-[''] before:absolute before:z-0 before:inset-0 before:opacity-0 data-[selected=true]:before:opacity-100 group-data-[disabled=true]/tr:text-foreground-300 group-data-[disabled=true]/tr:cursor-not-allowed before:bg-default/60 data-[selected=true]:text-default-foreground first:before:rounded-s-lg last:before:rounded-e-lg text-start`} + key={cell.id} + > + {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} +
+
+ } + {state.profiles.length > 0 && screenSize === ScreenSize.SM && +
+ <> + {table.getRowModel().rows.map((row) => { + return ( +
+
+
+ <> + {row.getVisibleCells().map((cell) => { + return ( + <> + {cell.column.columnDef.header !== 'Actions' && + <> +
+ {flexRender(cell.column.columnDef.cell, cell.getContext())} +
+
+ +
+ + } + + ) + })} + +
+
+
+ ) + })} + +
+ } +
+
+ {state.isFormOpen && + onOpenCloseProfileForm(mode)} + profileId={state.selectedProfileId} + /> + } + + ) +} + +export default Profiles; \ No newline at end of file diff --git a/client/src/components/profiles/ProfilesState.interface.ts b/client/src/components/profiles/ProfilesState.interface.ts new file mode 100644 index 0000000..2d5b38f --- /dev/null +++ b/client/src/components/profiles/ProfilesState.interface.ts @@ -0,0 +1,14 @@ +import { FormMode } from '../../enums/formMode'; +import { Alert } from '../../interfaces'; +import { Profile } from './Profile.interface'; + +export interface ProfilesState { + alert: Alert | undefined; + profiles: Profile[]; + formMode: FormMode; + isConfirmDialogLoading: boolean; + isConfirmDialogOpen: boolean; + isFormOpen: boolean; + isLoading: boolean; + selectedProfileId: string | undefined; +} \ No newline at end of file diff --git a/client/src/components/profiles/reducer.ts b/client/src/components/profiles/reducer.ts new file mode 100644 index 0000000..5916620 --- /dev/null +++ b/client/src/components/profiles/reducer.ts @@ -0,0 +1,93 @@ +import { FormMode } from '../../enums/formMode'; +import { ProfilesState } from './ProfilesState.interface' +import { Profile } from './Profile.interface'; +import { Alert } from '../../interfaces'; + +type Action = + | { + type: 'SET_DELETE'; + payload: { + isConfirmationDialogOpen: boolean; + selectedProfileId: string | undefined; + }; + } + | { type: 'SET_PROFILES'; payload: Profile[] } + | { type: 'SET_ALERT'; payload: Alert | undefined } + | { type: 'SET_FORM_MODE'; payload: FormMode } + | { type: 'SET_IS_CONFIRMATION_DIALOG_LOADING'; payload: boolean } + | { type: 'SET_IS_LOADING'; payload: boolean } + | { + type: 'SET_OPEN_CLOSE_PROFILE_FORM'; + payload: { + formMode: FormMode; + selectedProfileId: string | undefined; + isFormOpen: boolean; + }; + }; + +export const initialState: ProfilesState = { + alert: undefined, + formMode: FormMode.CANCEL, + isConfirmDialogLoading: false, + isConfirmDialogOpen: false, + isFormOpen: false, + isLoading: false, + profiles: [], + selectedProfileId: undefined +}; + +export const reducer = ( + state: ProfilesState, + action: Action +): ProfilesState => { + switch (action.type) { + case 'SET_DELETE': { + return { + ...state, + isConfirmDialogOpen: action.payload.isConfirmationDialogOpen, + selectedProfileId: action.payload.selectedProfileId + }; + } + case 'SET_PROFILES': { + return { + ...state, + profiles: action.payload + }; + } + case 'SET_ALERT': { + return { + ...state, + alert: action.payload + }; + } + case 'SET_FORM_MODE': { + return { + ...state, + formMode: action.payload + }; + } + case 'SET_IS_CONFIRMATION_DIALOG_LOADING': { + return { + ...state, + isConfirmDialogLoading: action.payload + }; + } + case 'SET_IS_LOADING': { + return { + ...state, + isLoading: action.payload + }; + } + case 'SET_OPEN_CLOSE_PROFILE_FORM': { + return { + ...state, + formMode: action.payload.formMode, + isFormOpen: action.payload.isFormOpen, + selectedProfileId: action.payload.selectedProfileId + }; + } + default: { + return state; + } + } +}; diff --git a/client/src/components/projectForm/ProjectForm.tsx b/client/src/components/projectForm/ProjectForm.tsx new file mode 100644 index 0000000..11609e7 --- /dev/null +++ b/client/src/components/projectForm/ProjectForm.tsx @@ -0,0 +1,310 @@ +import { useEffect, useReducer } from 'react'; +import { useForm, Controller, FormProvider } from 'react-hook-form'; +import { initialState, reducer } from './reducer'; +import { ProjectFormProps } from './ProjectFormProps.interface'; +import { FormMode } from '../../enums/formMode'; +import httpClient from '../../httpClient/httpClient'; +import { AxiosError, AxiosInstance, AxiosResponse } from 'axios'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faSave, faXmark } from '@fortawesome/free-solid-svg-icons'; +import { ScreenSize } from '../../enums/screenSize'; +import { useBreakpoints } from '../../hooks/breakpoints/UseBreakpoints'; +import { useProfiles } from '../../hooks/profiles/UseProfiles'; +import { Profile } from '../profiles/Profile.interface'; + +const ProjectForm = ({ isDrawerOpen, mode, onOpenClose, projectId }: ProjectFormProps) => { + const [state, dispatch] = useReducer(reducer, initialState); + const { profiles } = useProfiles() + const defaultValues = { + profileId: '', + name: '', + iconName: '', + summary: '', + repoUrl: '', + siteUrl: '', + order: '', + statusId: 1 + } + const methods = useForm({ + defaultValues: defaultValues + }); + // const watchRepoName = methods.watch(['repoName']) + const { screenSize } = useBreakpoints(); + + const onCancel = () => { + methods.reset(defaultValues); + dispatch({ type: 'SET_IS_DISABLED', payload: false }); + onOpenClose(FormMode.CANCEL); + }; + + const onSubmit = async (data: unknown) => { + try { + dispatch({ type: 'SET_IS_LOADING', payload: true }); + console.log(data) + if (!projectId) { + await httpClient.post(`api/projects`, data); + } else { + await httpClient.put(`api/projects/project/${projectId}`, data); + } + + methods.reset(defaultValues); + dispatch({ type: 'SET_IS_DISABLED', payload: false }); + onOpenClose(FormMode.CANCEL); + } catch (error) { + const axiosError = error as AxiosError; + console.log(axiosError) + dispatch({ type: 'SET_ERROR', payload: axiosError.message }); + } finally { + dispatch({ type: 'SET_IS_LOADING', payload: false }); + } + }; + + // useEffect(() => { + // if (watchRepoName.length > 0) { + // methods.setValue('rowKey', watchRepoName[0]) + // } + // }, [watchRepoName]) + + useEffect(() => { + if (mode === FormMode.VIEW) { + dispatch({ type: 'SET_IS_DISABLED', payload: true }); + } + }, [mode]); + + useEffect(() => { + const getProject = async () => { + try { + dispatch({ type: 'SET_IS_LOADING', payload: true }); + + const response: AxiosResponse = await httpClient.get( + `api/projects/project/${projectId}` + ); + const entry = response.data; + + methods.reset(entry); + } catch (error) { + const axiosError = error as AxiosError; + console.log(axiosError) + dispatch({ type: 'SET_ERROR', payload: axiosError.message }); + } finally { + dispatch({ type: 'SET_IS_LOADING', payload: false }); + } + }; + + if (projectId && isDrawerOpen) { + getProject(); + } + }, [projectId]); + + useEffect(() => { + if (profiles && FormMode.ADD) { + const newProfileOptions = profiles + + newProfileOptions.unshift({ + id: '', + name: '', + headline: '', + summary: '', + userId: '', + experiences: [], + skills: [] + }) + + dispatch({ type: 'SET_PROFILE_OPTIONS', payload: newProfileOptions }); + } + }, [profiles]); + + return ( +
+ {}} checked={isDrawerOpen} /> +
+ +
+ +
+
+
+

+ {`${mode.toString().toLowerCase().charAt(0).toUpperCase() + mode.toString().slice(1).toLowerCase()} Project`} +

+
+
+ +
+
+ Profile +
+
+ { + return ( + + ); + }} + /> +
+
+ Order +
+
+ ( + + )} + /> +
+
+ Name * +
+
+ ( + + )} + /> +
+
+ Icon Name +
+
+ ( + + )} + /> +
+
+ Repo URL +
+
+ ( + + )} + /> +
+
+ Site URL +
+
+ ( + + )} + /> +
+
+ Summary +
+
+ ( + + )} + /> +
+
+ + {mode.toString() !== FormMode.VIEW && ( + + )} +
+
+
+
+
+
+
+ ) +} + +export default ProjectForm; \ No newline at end of file diff --git a/client/src/components/projectForm/ProjectFormProps.interface.ts b/client/src/components/projectForm/ProjectFormProps.interface.ts new file mode 100644 index 0000000..9af6324 --- /dev/null +++ b/client/src/components/projectForm/ProjectFormProps.interface.ts @@ -0,0 +1,8 @@ +import { FormMode } from "../../enums/formMode"; + +export interface ProjectFormProps { + projectId?: string; + isDrawerOpen: boolean; + mode: FormMode; + onOpenClose: (mode: FormMode) => void; +} \ No newline at end of file diff --git a/client/src/components/projectForm/ProjectFormState.interface.ts b/client/src/components/projectForm/ProjectFormState.interface.ts new file mode 100644 index 0000000..7df5ad2 --- /dev/null +++ b/client/src/components/projectForm/ProjectFormState.interface.ts @@ -0,0 +1,8 @@ +import { Profile } from "../profiles/Profile.interface"; + +export interface ProjectFormState { + error: string | undefined; + isDisabled: boolean; + isLoading: boolean; + profileOptions: Profile[]; +} \ No newline at end of file diff --git a/client/src/components/projectForm/reducer.ts b/client/src/components/projectForm/reducer.ts new file mode 100644 index 0000000..d52009c --- /dev/null +++ b/client/src/components/projectForm/reducer.ts @@ -0,0 +1,50 @@ +import { Profile } from "../profiles/Profile.interface"; +import { ProjectFormState } from "./ProjectFormState.interface" + +type Action = +| { type: 'SET_ERROR'; payload: string | undefined } +| { type: 'SET_IS_DISABLED'; payload: boolean } +| { type: 'SET_IS_LOADING'; payload: boolean } +| { type: 'SET_PROFILE_OPTIONS'; payload: Profile[] }; + +export const initialState: ProjectFormState = { + error: undefined, + isDisabled: false, + isLoading: true, + profileOptions: [] +}; + +export const reducer = ( + state: ProjectFormState, + action: Action +): ProjectFormState => { + switch (action.type) { + case 'SET_ERROR': { + return { + ...state, + error: action.payload + }; + } + case 'SET_IS_DISABLED': { + return { + ...state, + isDisabled: action.payload + }; + } + case 'SET_IS_LOADING': { + return { + ...state, + isLoading: action.payload + }; + } + case 'SET_PROFILE_OPTIONS': { + return { + ...state, + profileOptions: action.payload + } + } + default: { + return state; + } + } +}; \ No newline at end of file diff --git a/client/src/components/projects/Project.interface.ts b/client/src/components/projects/Project.interface.ts new file mode 100644 index 0000000..4549979 --- /dev/null +++ b/client/src/components/projects/Project.interface.ts @@ -0,0 +1,10 @@ +export interface Project { + id: string; + iconName: string; + name: string; + order: string; + repoUrl: string; + siteUrl: string; + summary: string; + status: string; +} \ No newline at end of file diff --git a/client/src/components/projects/Projects.tsx b/client/src/components/projects/Projects.tsx new file mode 100644 index 0000000..3d8cc4b --- /dev/null +++ b/client/src/components/projects/Projects.tsx @@ -0,0 +1,372 @@ +import { Project } from './Project.interface'; +// import { +// Alert, +// Box, +// Button, +// Card, +// CardActions, +// CardContent, +// CardHeader, +// Container, +// Grid, +// Icon, +// IconName, +// Skeleton, +// Stack, +// Typography +// } from '@noahspan/noahspan-components'; +import { FormMode } from '../../enums/formMode'; +import { useEffect, useReducer } from 'react'; +import { initialState, reducer } from './reducer'; +import { AxiosError, AxiosInstance, AxiosResponse } from 'axios'; +import httpClient from '../../httpClient/httpClient' +import { useOidc } from '../../oidc'; +import { useBreakpoints } from "../../hooks/breakpoints/UseBreakpoints"; +import ProjectForm from '../projectForm/ProjectForm'; +import ConfirmationDialog from '../confirmationDialog/ConfirmationDialog'; +import ActionMenu from '../actionMenu/ActionMenu'; +import { ScreenSize } from '../../enums/screenSize'; +import { faEllipsisVertical, faEye, faPen, faPlane, faPlus, faTrash } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { UserRole } from '../../enums/userRole'; +import { useUserRole } from '../../hooks/userRole/UseUserRole'; +import Alert from '../../alert/Alert'; +import { CellContext, ColumnDef, flexRender, getCoreRowModel, getPaginationRowModel, useReactTable } from '@tanstack/react-table'; + +interface ActionsProps { + id: string; +} + +const Projects = () => { + const [state, dispatch] = useReducer(reducer, initialState) + const { userRole } = useUserRole(); + const { screenSize } = useBreakpoints(); + const { isUserLoggedIn } = useOidc(); + const Actions = ({ id }: ActionsProps) => { + return ( +
+
+ +
+ ) + } + const getProjects = async (): Promise => { + try { + const response: AxiosResponse = await httpClient.get( + `api/projects`, + ); + const projects: Project[] = response.data; + console.log(projects) + projects.sort((a, b) => Number(a.order) - Number(b.order)); + + return projects; + + return [] + } catch (error) { + const axiosError = error as AxiosError; + + throw new Error(axiosError.message); + } + }; + + const onOpenCloseProjectForm = (mode: FormMode, projectId?: string) => { + switch (mode) { + case FormMode.ADD: + case FormMode.EDIT: + case FormMode.VIEW: + dispatch({ + type: 'SET_OPEN_CLOSE_PROJECT_FORM', + payload: { + formMode: mode, + selectedProjectId: projectId, + isFormOpen: true + } + }); + + break; + case FormMode.CANCEL: + dispatch({ + type: 'SET_OPEN_CLOSE_PROJECT_FORM', + payload: { + formMode: mode, + selectedProjectId: undefined, + isFormOpen: false + } + }); + + break; + } + }; + + const onDeleteProject = (entryId: string) => { + dispatch({ + type: 'SET_DELETE', + payload: { isConfirmationDialogOpen: true, selectedProjectId: entryId } + }); + }; + + const onConfirmationDialogConfirm = async () => { + try { + dispatch({ type: 'SET_IS_CONFIRMATION_DIALOG_LOADING', payload: true }); + + await httpClient.delete(`api/projects/project/${state.selectedProjectId}`); + + dispatch({ + type: 'SET_DELETE', + payload: { isConfirmationDialogOpen: false, selectedProjectId: undefined } + }); + await getProjects(); + } catch (error) { + const axiosError = error as AxiosError; + + dispatch({ + type: 'SET_ALERT', + payload: { severity: 'error', message: `Loading of projects failed with the following message: ${axiosError.message}` } + }); + } finally { + dispatch({ type: 'SET_IS_CONFIRMATION_DIALOG_LOADING', payload: false }); + } + }; + + const onConfirmationDialogCancel = () => { + dispatch({ + type: 'SET_DELETE', + payload: { isConfirmationDialogOpen: false, selectedProjectId: undefined } + }); + }; + + const columns: ColumnDef[]= [ + { + id: 'name', + accessorKey: 'name', + header: 'Name' + }, + { + id: 'order', + accessorKey: 'order', + header: 'Order', + }, + { + id: 'repoUrl', + accessorKey: 'repoUrl', + header: 'Repo URL', + }, + { + id: 'siteUrl', + accessorKey: 'siteUrl', + header: 'Site URL', + }, + { + id: 'summary', + accessorKey: 'summary', + header: 'Summary' + }, + { + id: 'status', + accessorKey: 'status', + header: 'Status', + cell: (info: CellContext) => { + const status: any = info.getValue(); + + return status.name + } + }, + { + id: 'actions', + header: 'Actions', + meta: { + align: 'text-center', + headerAlign: 'text-center' + }, + cell: (info: CellContext) => { + return ( + + ) + } + } + ]; + + const table = useReactTable({ + data: state.projects, + columns: columns, + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel() + }); + + useEffect(() => { + const loadProjects = async () => { + try { + dispatch({ type: 'SET_IS_LOADING', payload: true }) + const projects = await getProjects(); + + if (projects.length > 0) { + dispatch({ type: 'SET_PROJECTS', payload: projects }); + + if (state.alert) { + dispatch({ type: 'SET_ALERT', payload: undefined }) + } + } else { + dispatch({ type: 'SET_ALERT', payload: { severity: 'info', message: 'No projects found.'}}); + } + } catch (error) { + dispatch({ + type: 'SET_ALERT', + payload: { severity: 'error', message: `Loading of projects failed with the following message: ${error}` } + }); + } finally { + dispatch({ type: 'SET_IS_LOADING', payload: false }) + } + } + + if (!state.isFormOpen || !state.isConfirmDialogOpen) { + loadProjects(); + } + }, [state.isFormOpen, state.isConfirmDialogOpen]); + + return ( + <> +
+
+
+

Projects

+
+
+ {!state.isLoading && userRole === UserRole.WRITE && + + } +
+ {!state.isLoading && state.alert && ( +
+ + dispatch({ type: 'SET_ALERT', payload: undefined }) + } + severity={state.alert.severity} + > + {state.alert.message} + +
+ )} + {state.projects.length > 0 && screenSize !== ScreenSize.SM && +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + ); + })} + + ))} + + + <> + {table.getRowModel().rows.map((row) => { + return ( + + {row.getVisibleCells().map((cell) => { + return ( + + ); + })} + + ); + })} + + +
+ {header.isPlaceholder ? null : ( +
+ {flexRender( + header.column.columnDef.header, + header.getContext() + )} + {/* {header.column.getCanFilter() ? ( +
+ +
+ ) : null} */} +
+ )} +
*]:z-1 [&>*]:relative outline-solid outline-transparent data-[focus-visible=true]:z-10 data-[focus-visible=true]:outline-2 data-[focus-visible=true]:outline-focus data-[focus-visible=true]:outline-offset-2 before:pointer-events-none before:content-[''] before:absolute before:z-0 before:inset-0 before:opacity-0 data-[selected=true]:before:opacity-100 group-data-[disabled=true]/tr:text-foreground-300 group-data-[disabled=true]/tr:cursor-not-allowed before:bg-default/60 data-[selected=true]:text-default-foreground first:before:rounded-s-lg last:before:rounded-e-lg text-start`} + key={cell.id} + > + {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} +
+
+ } + {state.projects.length > 0 && screenSize === ScreenSize.SM && +
+ <> + {table.getRowModel().rows.map((row) => { + return ( +
+
+
+ <> + {row.getVisibleCells().map((cell) => { + return ( + <> + {cell.column.columnDef.header !== 'Actions' && + <> +
+ {flexRender(cell.column.columnDef.cell, cell.getContext())} +
+
+ +
+ + } + + ) + })} + +
+
+
+ ) + })} + +
+ } +
+
+ {state.isFormOpen && + onOpenCloseProjectForm(mode)} + projectId={state.selectedProjectId} + /> + } + + ) +} + +export default Projects; \ No newline at end of file diff --git a/client/src/components/projects/ProjectsState.interface.ts b/client/src/components/projects/ProjectsState.interface.ts new file mode 100644 index 0000000..24c69a7 --- /dev/null +++ b/client/src/components/projects/ProjectsState.interface.ts @@ -0,0 +1,14 @@ +import { FormMode } from '../../enums/formMode'; +import { Alert } from '../../interfaces'; +import { Project } from './Project.interface'; + +export interface ProjectsState { + alert: Alert | undefined; + projects: Project[]; + formMode: FormMode; + isConfirmDialogLoading: boolean; + isConfirmDialogOpen: boolean; + isFormOpen: boolean; + isLoading: boolean; + selectedProjectId: string | undefined; +} \ No newline at end of file diff --git a/client/src/components/projects/reducer.ts b/client/src/components/projects/reducer.ts new file mode 100644 index 0000000..c98866f --- /dev/null +++ b/client/src/components/projects/reducer.ts @@ -0,0 +1,93 @@ +import { FormMode } from '../../enums/formMode'; +import { ProjectsState } from './ProjectsState.interface' +import { Project } from './Project.interface'; +import { Alert } from '../../interfaces'; + +type Action = + | { + type: 'SET_DELETE'; + payload: { + isConfirmationDialogOpen: boolean; + selectedProjectId: string | undefined; + }; + } + | { type: 'SET_PROJECTS'; payload: Project[] } + | { type: 'SET_ALERT'; payload: Alert | undefined } + | { type: 'SET_FORM_MODE'; payload: FormMode } + | { type: 'SET_IS_CONFIRMATION_DIALOG_LOADING'; payload: boolean } + | { type: 'SET_IS_LOADING'; payload: boolean } + | { + type: 'SET_OPEN_CLOSE_PROJECT_FORM'; + payload: { + formMode: FormMode; + selectedProjectId: string | undefined; + isFormOpen: boolean; + }; + }; + +export const initialState: ProjectsState = { + alert: undefined, + formMode: FormMode.CANCEL, + isConfirmDialogLoading: false, + isConfirmDialogOpen: false, + isFormOpen: false, + isLoading: false, + projects: [], + selectedProjectId: undefined +}; + +export const reducer = ( + state: ProjectsState, + action: Action +): ProjectsState => { + switch (action.type) { + case 'SET_DELETE': { + return { + ...state, + isConfirmDialogOpen: action.payload.isConfirmationDialogOpen, + selectedProjectId: action.payload.selectedProjectId + }; + } + case 'SET_PROJECTS': { + return { + ...state, + projects: action.payload + }; + } + case 'SET_ALERT': { + return { + ...state, + alert: action.payload + }; + } + case 'SET_FORM_MODE': { + return { + ...state, + formMode: action.payload + }; + } + case 'SET_IS_CONFIRMATION_DIALOG_LOADING': { + return { + ...state, + isConfirmDialogLoading: action.payload + }; + } + case 'SET_IS_LOADING': { + return { + ...state, + isLoading: action.payload + }; + } + case 'SET_OPEN_CLOSE_PROJECT_FORM': { + return { + ...state, + formMode: action.payload.formMode, + isFormOpen: action.payload.isFormOpen, + selectedProjectId: action.payload.selectedProjectId + }; + } + default: { + return state; + } + } +}; diff --git a/client/src/components/siteNav/SiteNav.tsx b/client/src/components/siteNav/SiteNav.tsx new file mode 100644 index 0000000..19b2c87 --- /dev/null +++ b/client/src/components/siteNav/SiteNav.tsx @@ -0,0 +1,172 @@ +import { useEffect, useState } from 'react'; +import { useAppContext } from '../../hooks/appContext/UseAppContext'; +import { AxiosResponse } from 'axios'; +import { User } from '@microsoft/microsoft-graph-types'; +import { useOidc } from '../../oidc'; +import httpClient from '../../httpClient/httpClient' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faBars, faSignIn, faSignOut, faUserTie } from '@fortawesome/free-solid-svg-icons' +import { NavLink, useLocation } from 'react-router-dom'; +import { useBreakpoints } from '../../hooks/breakpoints/UseBreakpoints'; +import { ScreenSize } from '../../enums/screenSize'; + +const SiteNav = () => { + const [userPhoto, setUserPhoto] = useState(); + const appContext = useAppContext(); + const { screenSize } = useBreakpoints(); + const { isUserLoggedIn, logout, login } = useOidc() + const pages = [ + { + name: 'Profiles', + path: '/' + }, + { + name: 'Experiences', + path: '/experiences' + }, + { + name: 'Projects', + path: '/projects' + }, + { + name: 'Skills', + path: '/skills' + } + ]; + const getUserProfile = async (): Promise => { + try { + const response: AxiosResponse = await httpClient.get(`api/msgraph/profile`); + const userProfile: User = response.data; + + return userProfile; + } catch (error) { + throw new Error(); + } + }; + const getUserPhoto = async (): Promise => { + try { + const response: AxiosResponse = await httpClient.get(`api/msgraph/photo`, { + responseType: 'arraybuffer' + }); + const arrayBufferView = new Uint8Array(response.data); + const blob = new Blob([arrayBufferView], { type: 'image/png' }); + const imageUrl = window.URL.createObjectURL(blob); + + return imageUrl; + } catch (error) { + throw new Error(); + } + }; + + const Brand = () => { + return ( + <> + + + + ) + } + + const Links = () => { + return ( + <> + {pages.map((page) => { + return ( +
  • {page.name}
  • + ) + })} + + ) + } + + useEffect(() => { + const setUserProfile = async () => { + try { + const userProfile = await getUserProfile(); + // const userPhoto = await getUserPhoto(); + + // setUserPhoto(userPhoto); + + appContext.dispatch({ + type: 'SET_USER_PROFILE', + payload: userProfile + }); + } catch (error) { + console.log(error); + } + }; + + if ( + isUserLoggedIn && + Object.keys(appContext.state.userProfile).length === 0 + ) { + setUserProfile(); + } + }, [isUserLoggedIn]); + + return ( +
    +
    + {screenSize === ScreenSize.SM || screenSize === ScreenSize.MD ? ( +
    +
    + +
    +
      + +
    +
    + ) : ( + + )} +
    +
    +
      + {screenSize === ScreenSize.SM || screenSize === ScreenSize.MD ? ( + + ) : ( + + )} +
    +
    +
    + {!isUserLoggedIn && + + } + {isUserLoggedIn && +
    +
    +
    + {userPhoto && +
    + +
    + } + {!userPhoto && +
    + NS +
    + } +
    +
    + +
    + } +
    +
    + ); +}; + +export default SiteNav; \ No newline at end of file diff --git a/client/src/components/skillCategories/SkillCategories.tsx b/client/src/components/skillCategories/SkillCategories.tsx new file mode 100644 index 0000000..bd0ebe1 --- /dev/null +++ b/client/src/components/skillCategories/SkillCategories.tsx @@ -0,0 +1,309 @@ +import { AxiosError, AxiosResponse } from "axios"; +import { FormMode } from "../../enums/formMode"; +import { initialState, reducer } from "./reducer"; +import httpClient from "../../httpClient/httpClient"; +import { SkillCategory } from "./SkillCategory.interface"; +import { ColumnDef, flexRender, getCoreRowModel, getPaginationRowModel, useReactTable } from "@tanstack/react-table"; +import { useEffect, useReducer } from "react"; +import { ScreenSize } from "../../enums/screenSize"; +import Alert from "../../alert/Alert"; +import { UserRole } from "../../enums/userRole"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faEllipsisVertical, faEye, faPen, faPlus, faTrash } from "@fortawesome/free-solid-svg-icons"; +import { useUserRole } from "../../hooks/userRole/UseUserRole"; +import { useBreakpoints } from "../../hooks/breakpoints/UseBreakpoints"; +import { useOidc } from "../../oidc"; +import SkillCategoryForm from '../SkillCategoryForm/SkillCategoryForm'; + +interface ActionsProps { + id: string; +} + +const SkillCategories = () => { + const [state, dispatch] = useReducer(reducer, initialState); + const { userRole } = useUserRole(); + const { screenSize } = useBreakpoints(); + const { isUserLoggedIn } = useOidc(); + const Actions = ({ id }: ActionsProps) => { + return ( +
    +
    + +
    + ) + } + + const getSkillCategories = async () => { + try { + let response: AxiosResponse; + + response = await httpClient.get( + `api/skill-categories` + ); + console.log(response) + if (response.data.length > 0) { + dispatch({ type: 'SET_SKILL_CATEGORIES', payload: response.data }); + + if (state.alert) { + dispatch({ type: 'SET_ALERT', payload: undefined }) + } + } else { + dispatch({ type: 'SET_ALERT', payload: { severity: 'info', message: 'No skill categories found.' }}) + dispatch({ type: 'SET_SKILL_CATEGORIES', payload: [] }); + } + } catch (error) { + const axiosError = error as AxiosError; + + dispatch({ + type: 'SET_ALERT', + payload: { severity: 'error', message: `Loading of skill categories failed with the following message: ${axiosError.message}`} + }) + } finally { + dispatch({ type: 'SET_IS_LOADING', payload: false }) + } + }; + + const onOpenCloseSkillCategoryForm = async (mode: FormMode, categoryId?: string) => { + switch (mode) { + case FormMode.ADD: + case FormMode.EDIT: + case FormMode.VIEW: + dispatch({ + type: 'SET_OPEN_CLOSE_ENTRY_FORM', + payload: { + formMode: mode, + selectedCategoryId: categoryId, + isFormOpen: true + } + }) + + break; + case FormMode.CANCEL: + dispatch({ + type: 'SET_OPEN_CLOSE_ENTRY_FORM', + payload: { + formMode: mode, + isFormOpen: false, + selectedCategoryId: undefined, + } + }) + + break; + } + }; + + const onDeleteSkill = (categoryId: string) => { + dispatch({ + type: 'SET_DELETE', + payload: { isConfirmDialogOpen: true, selectedCategoryId: categoryId } + }); + }; + + const onConfirmationDialogConfirm = async () => { + try { + dispatch({ type: 'SET_IS_CONFIRMATION_DIALOG_LOADING', payload: true }); + + await httpClient.delete(`api/skill-categories/${state.selectedCategoryId}`); + + dispatch({ + type: 'SET_DELETE', + payload: { isConfirmDialogOpen: false, selectedCategoryId: undefined } + }); + await getSkillCategories(); + } catch (error) { + const axiosError = error as AxiosError; + + dispatch({ + type: 'SET_ALERT', + payload: { severity: 'error', message: `Loading of skill categories failed with the following message: ${axiosError.message}`} + }); + } finally { + dispatch({ type: 'SET_IS_CONFIRMATION_DIALOG_LOADING', payload: false }); + } + }; + + const onConfirmationDialogCancel = () => { + dispatch({ + type: 'SET_DELETE', + payload: { isConfirmDialogOpen: false, selectedCategoryId: undefined } + }); + }; + + const columns: ColumnDef[] = [ + { + id: 'name', + accessorKey: 'name', + header: 'Name' + } + ]; + + const table = useReactTable({ + data: state.categories, + columns: columns, + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel() + }); + + useEffect(() => { + if (!state.isFormOpen) { + getSkillCategories(); + } + }, [state.isFormOpen]); + + return ( + <> +
    +
    +
    +

    Categories

    +
    +
    + {!state.isLoading && userRole === UserRole.WRITE && + + } +
    + {!state.isLoading && state.alert && ( +
    + + dispatch({ type: 'SET_ALERT', payload: undefined }) + } + severity={state.alert.severity} + > + {state.alert.message} + +
    + )} + {state.categories.length > 0 && screenSize !== ScreenSize.SM && +
    + + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + ); + })} + + ))} + + + <> + {table.getRowModel().rows.map((row) => { + return ( + + {row.getVisibleCells().map((cell) => { + return ( + + ); + })} + + ); + })} + + +
    + {header.isPlaceholder ? null : ( +
    + {flexRender( + header.column.columnDef.header, + header.getContext() + )} + {/* {header.column.getCanFilter() ? ( +
    + +
    + ) : null} */} +
    + )} +
    *]:z-1 [&>*]:relative outline-solid outline-transparent data-[focus-visible=true]:z-10 data-[focus-visible=true]:outline-2 data-[focus-visible=true]:outline-focus data-[focus-visible=true]:outline-offset-2 before:pointer-events-none before:content-[''] before:absolute before:z-0 before:inset-0 before:opacity-0 data-[selected=true]:before:opacity-100 group-data-[disabled=true]/tr:text-foreground-300 group-data-[disabled=true]/tr:cursor-not-allowed before:bg-default/60 data-[selected=true]:text-default-foreground first:before:rounded-s-lg last:before:rounded-e-lg text-start`} + key={cell.id} + > + {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} +
    +
    + } + {state.categories.length > 0 && screenSize === ScreenSize.SM && +
    + <> + {table.getRowModel().rows.map((row) => { + return ( +
    +
    +
    + <> + {row.getVisibleCells().map((cell) => { + return ( + <> + {cell.column.columnDef.header !== 'Actions' && + <> +
    + {flexRender(cell.column.columnDef.cell, cell.getContext())} +
    +
    + +
    + + } + + ) + })} + +
    +
    +
    + ) + })} + +
    + } +
    +
    + {state.isFormOpen && ( + onOpenCloseSkillCategoryForm(mode)} + categoryId={state.selectedCategoryId} + /> + )} + {/* {state.isConfirmDialogOpen && ( + + )} */} + + ); +} + +export default SkillCategories; \ No newline at end of file diff --git a/client/src/components/skillCategories/SkillCategoriesState.interface.ts b/client/src/components/skillCategories/SkillCategoriesState.interface.ts new file mode 100644 index 0000000..f85d16c --- /dev/null +++ b/client/src/components/skillCategories/SkillCategoriesState.interface.ts @@ -0,0 +1,14 @@ +import { FormMode } from "../../enums/formMode"; +import { Alert } from "../../interfaces"; +import { SkillCategory } from "./SkillCategory.interface"; + +export interface SkillCategoriesState { + alert: Alert | undefined; + categories: SkillCategory[]; + formMode: FormMode; + isConfirmDialogLoading: boolean; + isConfirmDialogOpen: boolean; + isFormOpen: boolean; + isLoading: boolean; + selectedCategoryId: string | undefined; +} \ No newline at end of file diff --git a/client/src/components/skillCategories/SkillCategory.interface.ts b/client/src/components/skillCategories/SkillCategory.interface.ts new file mode 100644 index 0000000..14bdf96 --- /dev/null +++ b/client/src/components/skillCategories/SkillCategory.interface.ts @@ -0,0 +1,4 @@ +export interface SkillCategory { + id: string; + name: string; +} \ No newline at end of file diff --git a/client/src/components/skillCategories/reducer.ts b/client/src/components/skillCategories/reducer.ts new file mode 100644 index 0000000..e388c56 --- /dev/null +++ b/client/src/components/skillCategories/reducer.ts @@ -0,0 +1,80 @@ +import { FormMode } from "../../enums/formMode"; +import { Alert } from "../../interfaces"; +import { SkillCategoriesState } from "./SkillCategoriesState.interface"; +import { SkillCategory } from "./SkillCategory.interface"; + +type Action = +| { type: 'SET_DELETE'; payload: { isConfirmDialogOpen: boolean, selectedCategoryId: string | undefined }} +| { type: 'SET_SKILL_CATEGORIES'; payload: SkillCategory[] } +| { type: 'SET_ALERT'; payload: Alert | undefined } +| { type: 'SET_FORM_MODE'; payload: FormMode } +| { type: 'SET_IS_CONFIRMATION_DIALOG_LOADING'; payload: boolean } +| { type: 'SET_IS_LOADING'; payload: boolean } +| { type: 'SET_OPEN_CLOSE_ENTRY_FORM'; payload: { formMode: FormMode; isFormOpen: boolean, selectedCategoryId: string | undefined }} + +export const initialState: SkillCategoriesState = { + alert: undefined, + categories: [], + formMode: FormMode.CANCEL, + isConfirmDialogLoading: false, + isConfirmDialogOpen: false, + isFormOpen: false, + isLoading: false, + selectedCategoryId: undefined +} + +export const reducer = ( + state: SkillCategoriesState, + action: Action +): SkillCategoriesState => { + switch (action.type) { + case 'SET_DELETE': { + return { + ...state, + isConfirmDialogOpen: action.payload.isConfirmDialogOpen, + selectedCategoryId: action.payload.selectedCategoryId + } + } + case 'SET_SKILL_CATEGORIES': { + return { + ...state, + categories: action.payload + } + } + case 'SET_ALERT': { + return { + ...state, + alert: action.payload + } + } + case 'SET_FORM_MODE': { + return { + ...state, + formMode: action.payload + } + } + case 'SET_IS_CONFIRMATION_DIALOG_LOADING': { + return { + ...state, + isConfirmDialogLoading: action.payload + } + } + case 'SET_IS_LOADING': { + return { + ...state, + isLoading: action.payload + } + } + case 'SET_OPEN_CLOSE_ENTRY_FORM': { + return { + ...state, + formMode: action.payload.formMode, + isFormOpen: action.payload.isFormOpen, + selectedCategoryId: action.payload.selectedCategoryId + } + } + default: { + return state; + } + } +} \ No newline at end of file diff --git a/client/src/components/skills/Skill.interface.ts b/client/src/components/skills/Skill.interface.ts new file mode 100644 index 0000000..24e1eaa --- /dev/null +++ b/client/src/components/skills/Skill.interface.ts @@ -0,0 +1,7 @@ +export interface Skill { + id: string; + name: string; + category: string; + level: string; + rank: string; +} \ No newline at end of file diff --git a/client/src/components/skills/Skills.tsx b/client/src/components/skills/Skills.tsx new file mode 100644 index 0000000..1d3141f --- /dev/null +++ b/client/src/components/skills/Skills.tsx @@ -0,0 +1,353 @@ +import { AxiosError, AxiosResponse } from "axios"; +import { useBreakpoints } from "../../hooks/breakpoints/UseBreakpoints"; +import { CellContext, ColumnDef, flexRender, getCoreRowModel, getPaginationRowModel, useReactTable } from "@tanstack/react-table"; +import { useEffect, useReducer } from "react"; +import { ScreenSize } from "../../enums/screenSize"; +import { faEllipsisVertical, faEye, faPen, faPlus, faTrash } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { FormMode } from "../../enums/formMode"; +import { useOidc } from "../../oidc"; +import { initialState, reducer } from "./reducer"; +import httpClient from '../../httpClient/httpClient' +import { UserRole } from "../../enums/userRole"; +import Alert from "../../alert/Alert"; +// import ConfirmationDialog from "../confirmationDialog/ConfirmationDialog"; +import { useUserRole } from "../../hooks/userRole/UseUserRole"; +import { Skill } from "./Skill.interface"; +import SkillForm from "../SkillForm/SkillForm"; + +interface ActionsProps { + id: string; +} + +const Skills = () => { + const [state, dispatch] = useReducer(reducer, initialState); + const { userRole } = useUserRole(); + const { screenSize } = useBreakpoints(); + const { isUserLoggedIn } = useOidc(); + const Actions = ({ id }: ActionsProps) => { + return ( +
    +
    + +
    + ) + } + + const getSkills = async () => { + try { + let response: AxiosResponse; + + response = await httpClient.get( + `api/skills` + ); + + if (response.data.length > 0) { + dispatch({ type: 'SET_SKILLS', payload: response.data }); + + if (state.alert) { + dispatch({ type: 'SET_ALERT', payload: undefined }) + } + } else { + dispatch({ type: 'SET_ALERT', payload: { severity: 'info', message: 'No skills found.' }}) + dispatch({ type: 'SET_SKILLS', payload: [] }); + } + } catch (error) { + const axiosError = error as AxiosError; + + dispatch({ + type: 'SET_ALERT', + payload: { severity: 'error', message: `Loading of skills failed with the following message: ${axiosError.message}`} + }) + } finally { + dispatch({ type: 'SET_IS_LOADING', payload: false }) + } + }; + + const onOpenCloseSkillForm = async (mode: FormMode, skillId?: string) => { + switch (mode) { + case FormMode.ADD: + case FormMode.EDIT: + case FormMode.VIEW: + dispatch({ + type: 'SET_OPEN_CLOSE_ENTRY_FORM', + payload: { + formMode: mode, + selectedSkillId: skillId, + isFormOpen: true + } + }) + + break; + case FormMode.CANCEL: + dispatch({ + type: 'SET_OPEN_CLOSE_ENTRY_FORM', + payload: { + formMode: mode, + selectedSkillId: undefined, + isFormOpen: false + } + }) + + break; + } + }; + + const onDeleteSkill = (skillId: string) => { + dispatch({ + type: 'SET_DELETE', + payload: { isConfirmDialogOpen: true, selectedSkillId: skillId } + }); + }; + + const onConfirmationDialogConfirm = async () => { + try { + dispatch({ type: 'SET_IS_CONFIRMATION_DIALOG_LOADING', payload: true }); + + await httpClient.delete(`api/skills/${state.selectedSkillId}`); + + dispatch({ + type: 'SET_DELETE', + payload: { isConfirmDialogOpen: false, selectedSkillId: undefined } + }); + await getSkills(); + } catch (error) { + const axiosError = error as AxiosError; + + dispatch({ + type: 'SET_ALERT', + payload: { severity: 'error', message: `Loading of skills failed with the following message: ${axiosError.message}`} + }); + } finally { + dispatch({ type: 'SET_IS_CONFIRMATION_DIALOG_LOADING', payload: false }); + } + }; + + const onConfirmationDialogCancel = () => { + dispatch({ + type: 'SET_DELETE', + payload: { isConfirmDialogOpen: false, selectedSkillId: undefined } + }); + }; + + const columns: ColumnDef[]= [ + { + id: 'name', + accessorKey: 'name', + header: 'Name' + }, + { + id: 'category', + accessorKey: 'category', + header: 'Category', + cell: (info: CellContext) => { + const category: any = info.getValue(); + + return category.name + } + }, + { + id: 'level', + accessorKey: 'level', + header: 'Level', + cell: (info: CellContext) => { + const level: any = info.getValue(); + + return level.name + } + }, + { + id: 'rank', + accessorKey: 'rank', + header: 'Rank', + cell: (info: CellContext) => { + const rank: any = info.getValue(); + + return rank.name + } + }, + { + id: 'actions', + header: 'Actions', + meta: { + align: 'text-center', + headerAlign: 'text-center' + }, + cell: (info: CellContext) => { + return ( + + ) + } + } + ]; + + const table = useReactTable({ + data: state.skills, + columns: columns, + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel() + }); + + useEffect(() => { + if (!state.isFormOpen) { + getSkills(); + } + }, [state.isFormOpen]); + + return ( + <> +
    +
    +
    +

    Skills

    +
    +
    + {!state.isLoading && userRole === UserRole.WRITE && + + } +
    + {!state.isLoading && state.alert && ( +
    + + dispatch({ type: 'SET_ALERT', payload: undefined }) + } + severity={state.alert.severity} + > + {state.alert.message} + +
    + )} + {state.skills.length > 0 && screenSize !== ScreenSize.SM && +
    + + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + ); + })} + + ))} + + + <> + {table.getRowModel().rows.map((row) => { + return ( + + {row.getVisibleCells().map((cell) => { + return ( + + ); + })} + + ); + })} + + +
    + {header.isPlaceholder ? null : ( +
    + {flexRender( + header.column.columnDef.header, + header.getContext() + )} + {/* {header.column.getCanFilter() ? ( +
    + +
    + ) : null} */} +
    + )} +
    *]:z-1 [&>*]:relative outline-solid outline-transparent data-[focus-visible=true]:z-10 data-[focus-visible=true]:outline-2 data-[focus-visible=true]:outline-focus data-[focus-visible=true]:outline-offset-2 before:pointer-events-none before:content-[''] before:absolute before:z-0 before:inset-0 before:opacity-0 data-[selected=true]:before:opacity-100 group-data-[disabled=true]/tr:text-foreground-300 group-data-[disabled=true]/tr:cursor-not-allowed before:bg-default/60 data-[selected=true]:text-default-foreground first:before:rounded-s-lg last:before:rounded-e-lg text-start`} + key={cell.id} + > + {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} +
    +
    + } + {state.skills.length > 0 && screenSize === ScreenSize.SM && +
    + <> + {table.getRowModel().rows.map((row) => { + return ( +
    +
    +
    + <> + {row.getVisibleCells().map((cell) => { + return ( + <> + {cell.column.columnDef.header !== 'Actions' && + <> +
    + {flexRender(cell.column.columnDef.cell, cell.getContext())} +
    +
    + +
    + + } + + ) + })} + +
    +
    +
    + ) + })} + +
    + } +
    +
    + {state.isFormOpen && ( + onOpenCloseSkillForm(mode)} + skillId={state.selectedSkillId} + /> + )} + {/* {state.isConfirmDialogOpen && ( + + )} */} + + ); +}; + +export default Skills; \ No newline at end of file diff --git a/client/src/components/skills/SkillsState.interface.ts b/client/src/components/skills/SkillsState.interface.ts new file mode 100644 index 0000000..ee449b3 --- /dev/null +++ b/client/src/components/skills/SkillsState.interface.ts @@ -0,0 +1,15 @@ +import { FormMode } from '../../enums/formMode'; +import { Alert } from '../../interfaces'; +import { Skill } from './Skill.interface'; + +export interface SkillsState { + activeTab: string; + alert: Alert | undefined; + skills: Skill[]; + formMode: FormMode; + isConfirmDialogLoading: boolean; + isConfirmDialogOpen: boolean; + isFormOpen: boolean; + isLoading: boolean; + selectedSkillId: string | undefined; +} \ No newline at end of file diff --git a/client/src/components/skills/reducer.ts b/client/src/components/skills/reducer.ts new file mode 100644 index 0000000..e311ff0 --- /dev/null +++ b/client/src/components/skills/reducer.ts @@ -0,0 +1,101 @@ +import { FormMode } from "../../enums/formMode"; +import { Alert } from "../../interfaces/Alert.interface"; +import { Skill } from "./Skill.interface"; +import { SkillsState } from './SkillsState.interface' + +type Action = + | { + type: 'SET_DELETE'; + payload: { + isConfirmDialogOpen: boolean; + selectedSkillId: string | undefined; + } + } + | { type: 'SET_SKILLS'; payload: Skill[] } + | { type: 'SET_ALERT'; payload: Alert | undefined } + | { type: 'SET_FORM_MODE'; payload: FormMode } + | { type: 'SET_IS_CONFIRMATION_DIALOG_LOADING'; payload: boolean } + | { type: 'SET_IS_LOADING'; payload: boolean } + | { + type: 'SET_OPEN_CLOSE_ENTRY_FORM'; + payload: { + formMode: FormMode; + selectedSkillId: string | undefined; + isFormOpen: boolean; + } + } + | { type: 'SET_ACTIVE_TAB'; payload: string } + +export const initialState: SkillsState = { + activeTab: 'skills', + alert: undefined, + formMode: FormMode.CANCEL, + isConfirmDialogLoading: false, + isConfirmDialogOpen: false, + isFormOpen: false, + isLoading: false, + skills: [], + selectedSkillId: undefined +} + +export const reducer = ( + state: SkillsState, + action: Action +): SkillsState => { + switch (action.type) { + case 'SET_DELETE': { + return { + ...state, + isConfirmDialogOpen: action.payload.isConfirmDialogOpen, + selectedSkillId: action.payload.selectedSkillId + } + } + case 'SET_SKILLS': { + return { + ...state, + skills: action.payload + } + } + case 'SET_ALERT': { + return { + ...state, + alert: action.payload + } + } + case 'SET_FORM_MODE': { + return { + ...state, + formMode: action.payload + } + } + case 'SET_IS_CONFIRMATION_DIALOG_LOADING': { + return { + ...state, + isConfirmDialogLoading: action.payload + } + } + case 'SET_IS_LOADING': { + return { + ...state, + isLoading: action.payload + } + } + case 'SET_OPEN_CLOSE_ENTRY_FORM': { + return { + ...state, + formMode: action.payload.formMode, + isFormOpen: action.payload.isFormOpen, + selectedSkillId: action.payload.selectedSkillId + } + } + case 'SET_ACTIVE_TAB': { + return { + ...state, + activeTab: action.payload + } + } + default: { + return state; + } + } +} diff --git a/client/src/components/skillsPage/SkillsPage.tsx b/client/src/components/skillsPage/SkillsPage.tsx new file mode 100644 index 0000000..dbfccf0 --- /dev/null +++ b/client/src/components/skillsPage/SkillsPage.tsx @@ -0,0 +1,34 @@ +import { useReducer } from "react" +import { initialState, reducer } from "./reducer" +import Skills from "../skills/Skills" +import SkillCategories from "../skillCategories/SkillCategories" + +const SkillsPage = () => { + const [state, dispatch] = useReducer(reducer, initialState) + + const onTabClicked = (event: any) => { + dispatch({ type: 'SET_ACTIVE_TAB', payload: event.target.ariaLabel }) + } + + return ( +
    + +
    + +
    + + +
    + +
    +
    + ) +} + +export default SkillsPage; \ No newline at end of file diff --git a/client/src/components/skillsPage/SkillsPageState.interface.ts b/client/src/components/skillsPage/SkillsPageState.interface.ts new file mode 100644 index 0000000..c8d1e91 --- /dev/null +++ b/client/src/components/skillsPage/SkillsPageState.interface.ts @@ -0,0 +1,3 @@ +export interface SkillsPageState { + activeTab: string; +} \ No newline at end of file diff --git a/client/src/components/skillsPage/reducer.ts b/client/src/components/skillsPage/reducer.ts new file mode 100644 index 0000000..9edf55d --- /dev/null +++ b/client/src/components/skillsPage/reducer.ts @@ -0,0 +1,22 @@ +import { SkillsPageState } from "./SkillsPageState.interface"; + +type Action = +| { type: 'SET_ACTIVE_TAB'; payload: string } + +export const initialState: SkillsPageState = { + activeTab: 'skills' +} + +export const reducer = ( + state: SkillsPageState, + action: Action +): SkillsPageState => { + switch (action.type) { + case 'SET_ACTIVE_TAB': { + return { + ...state, + activeTab: action.payload + } + } + } +} \ No newline at end of file diff --git a/client/src/context/appContext/AppContext.tsx b/client/src/context/appContext/AppContext.tsx new file mode 100644 index 0000000..0854cd1 --- /dev/null +++ b/client/src/context/appContext/AppContext.tsx @@ -0,0 +1,5 @@ +import { Context, createContext } from 'react'; +import { AppContextProps } from './AppContextProps.interface'; + +export const AppContext: Context = + createContext({} as AppContextProps); \ No newline at end of file diff --git a/client/src/context/appContext/AppContextProps.interface.ts b/client/src/context/appContext/AppContextProps.interface.ts new file mode 100644 index 0000000..6734f22 --- /dev/null +++ b/client/src/context/appContext/AppContextProps.interface.ts @@ -0,0 +1,7 @@ +import { Action } from './reducer'; +import { AppContextState } from './AppContextState.interface'; + +export interface AppContextProps { + state: AppContextState; + dispatch: React.Dispatch; +} diff --git a/client/src/context/appContext/AppContextProvider.tsx b/client/src/context/appContext/AppContextProvider.tsx new file mode 100644 index 0000000..3dcbc52 --- /dev/null +++ b/client/src/context/appContext/AppContextProvider.tsx @@ -0,0 +1,29 @@ +import { useMemo, useReducer } from 'react'; +import { AppContext } from './AppContext'; +import { AppContextProviderProps } from './AppContextProviderProps.interface'; +import { AppContextProps } from './AppContextProps.interface'; +import { AppContextState } from './AppContextState.interface'; +import { reducer } from './reducer'; + +const AppContextProvider = ( + props: AppContextProviderProps +) => { + const intialState: AppContextState = { + userProfile: {} + }; + const [state, dispatch] = useReducer(reducer, intialState); + const contextValue: AppContextProps = useMemo(() => { + return { + state, + dispatch + }; + }, [state, dispatch]); + + return ( + + {props.children} + + ); +}; + +export default AppContextProvider; diff --git a/client/src/context/appContext/AppContextProviderProps.interface.ts b/client/src/context/appContext/AppContextProviderProps.interface.ts new file mode 100644 index 0000000..6f1d028 --- /dev/null +++ b/client/src/context/appContext/AppContextProviderProps.interface.ts @@ -0,0 +1,3 @@ +export interface AppContextProviderProps { + children: React.ReactNode; +} diff --git a/client/src/context/appContext/AppContextState.interface.ts b/client/src/context/appContext/AppContextState.interface.ts new file mode 100644 index 0000000..2fe9791 --- /dev/null +++ b/client/src/context/appContext/AppContextState.interface.ts @@ -0,0 +1,5 @@ +import { User } from '@microsoft/microsoft-graph-types'; + +export interface AppContextState { + userProfile: User; +} diff --git a/client/src/context/appContext/reducer.ts b/client/src/context/appContext/reducer.ts new file mode 100644 index 0000000..50fd0cc --- /dev/null +++ b/client/src/context/appContext/reducer.ts @@ -0,0 +1,22 @@ +import { User } from '@microsoft/microsoft-graph-types'; +import { AppContextState } from './AppContextState.interface'; + +export type Action = + | { type: 'SET_USER_PROFILE'; payload: User }; + +export const reducer = ( + state: AppContextState, + action: Action +): AppContextState => { + switch (action.type) { + case 'SET_USER_PROFILE': { + return { + ...state, + userProfile: action.payload + }; + } + default: { + return state; + } + } +}; diff --git a/client/src/enums/formMode.ts b/client/src/enums/formMode.ts new file mode 100644 index 0000000..f1eff1f --- /dev/null +++ b/client/src/enums/formMode.ts @@ -0,0 +1,6 @@ +export enum FormMode { + ADD = 'ADD', + EDIT = 'EDIT', + VIEW = 'VIEW', + CANCEL = 'CANCEL' +} \ No newline at end of file diff --git a/client/src/enums/screenSize.ts b/client/src/enums/screenSize.ts new file mode 100644 index 0000000..e5b6492 --- /dev/null +++ b/client/src/enums/screenSize.ts @@ -0,0 +1,7 @@ +export enum ScreenSize { + SM = 'SM', + MD = 'MD', + LG = 'LG', + XL = 'XL', + XXL = 'XXL' +} \ No newline at end of file diff --git a/client/src/enums/userRole.ts b/client/src/enums/userRole.ts new file mode 100644 index 0000000..0877b8b --- /dev/null +++ b/client/src/enums/userRole.ts @@ -0,0 +1,4 @@ +export enum UserRole { + READ = 'Root.Read', + WRITE = 'Root.Write' +} \ No newline at end of file diff --git a/client/src/hooks/accessToken/UseAccessToken.tsx b/client/src/hooks/accessToken/UseAccessToken.tsx new file mode 100644 index 0000000..336d9e5 --- /dev/null +++ b/client/src/hooks/accessToken/UseAccessToken.tsx @@ -0,0 +1,32 @@ +import { + AuthenticationResult, + InteractionRequiredAuthError +} from '@azure/msal-browser'; +import { useMsal } from '@azure/msal-react'; + +export const useAccessToken = () => { + const { accounts, instance } = useMsal(); + const getAccessToken = async () => { + const tokenRequest = { + account: accounts[0], + scopes: [`api://${import.meta.env.VITE_CLIENT_ID}/user_impersonation`] + }; + + try { + const response: AuthenticationResult = + await instance.acquireTokenSilent(tokenRequest); + + return `Bearer ${response.accessToken}`; + } catch (error) { + if (error instanceof InteractionRequiredAuthError) { + await instance.acquireTokenRedirect(tokenRequest); + } + + throw error; + } + }; + + return { + getAccessToken + }; +}; diff --git a/client/src/hooks/appContext/UseAppContext.tsx b/client/src/hooks/appContext/UseAppContext.tsx new file mode 100644 index 0000000..abb557a --- /dev/null +++ b/client/src/hooks/appContext/UseAppContext.tsx @@ -0,0 +1,11 @@ +import { useContext } from 'react'; +import { AppContext } from '../../context/appContext/AppContext'; + +export const useAppContext = () => { + const { state, dispatch } = useContext(AppContext); + + return { + state, + dispatch + }; +}; \ No newline at end of file diff --git a/client/src/hooks/breakpoints/UseBreakpoints.tsx b/client/src/hooks/breakpoints/UseBreakpoints.tsx new file mode 100644 index 0000000..81a9015 --- /dev/null +++ b/client/src/hooks/breakpoints/UseBreakpoints.tsx @@ -0,0 +1,62 @@ +import { useEffect, useState } from 'react'; +import { ScreenSize } from '../../enums/screenSize'; + +export const useBreakpoints = () => { + const [screenSize, setScreenSize] = useState(); + const windowWidth = window.innerWidth; + + const getWindowSize = (width: number): ScreenSize => { + let size!: ScreenSize; + + switch (true) { + case width < 640: { + size = ScreenSize.SM; + + break; + } + case width >= 640 && width < 1024: { + size = ScreenSize.MD; + + break; + } + case width >= 1024 && width < 1280: { + size = ScreenSize.LG + + break; + } + case width >= 1280 && width < 1536: { + size = ScreenSize.XL; + + break; + } + case width >= 1536: { + size = ScreenSize.XXL; + + break; + } + } + + return size; + } + + const onWindowResize = () => { + const width: number = window.innerWidth; + const newScreenSize: ScreenSize = getWindowSize(width); + + setScreenSize(newScreenSize); + } + + useEffect(() => { + onWindowResize() + + window.addEventListener('resize', onWindowResize); + + return () => { + window.removeEventListener('resize', onWindowResize); + } + }, []) + + return { + screenSize + } +} \ No newline at end of file diff --git a/client/src/hooks/profiles/UseProfiles.tsx b/client/src/hooks/profiles/UseProfiles.tsx new file mode 100644 index 0000000..4b16133 --- /dev/null +++ b/client/src/hooks/profiles/UseProfiles.tsx @@ -0,0 +1,41 @@ +import { useEffect, useState } from 'react'; +import { AxiosInstance, AxiosResponse } from 'axios'; +import httpClient from '../../httpClient/httpClient'; +import { Profile } from '../../components/profiles/Profile.interface'; + +export const useProfiles = () => { + const [profiles, setProfiles] = useState(); + + const getProfile = async (profileId: string) => { + try { + const response: AxiosResponse = await httpClient.get( + `api/profile/${profileId}` + ); + + return response.data; + } catch (error) { + return error; + } + }; + + useEffect(() => { + const getProfiles = async () => { + try { + const response: AxiosResponse = await httpClient.get( + `/api/profiles` + ); + + setProfiles(response.data); + } catch (error) { + return error; + } + }; + + getProfiles(); + }, []); + + return { + getProfile, + profiles + }; +}; diff --git a/client/src/hooks/skills/UseSkills.tsx b/client/src/hooks/skills/UseSkills.tsx new file mode 100644 index 0000000..e2d90b9 --- /dev/null +++ b/client/src/hooks/skills/UseSkills.tsx @@ -0,0 +1,29 @@ +import { useEffect, useState } from "react"; +import { Skill } from "../../components/skills/Skill.interface"; +import { AxiosResponse } from "axios"; +import httpClient from "../../httpClient/httpClient"; + + +export const useSkills = () => { + const [skills, setSkills] = useState(); + + useEffect(() => { + const getSkills = async () => { + try { + const response: AxiosResponse = await httpClient.get( + `/api/skills` + ); + + setSkills(response.data); + } catch (error) { + return error; + } + } + + getSkills() + }, []) + + return { + skills + } +} \ No newline at end of file diff --git a/client/src/hooks/userRole/UseUserRole.tsx b/client/src/hooks/userRole/UseUserRole.tsx new file mode 100644 index 0000000..75c2808 --- /dev/null +++ b/client/src/hooks/userRole/UseUserRole.tsx @@ -0,0 +1,31 @@ +import { useEffect, useState } from "react"; +import { useOidc } from "../../oidc"; +import { UserRole } from "../../enums/userRole"; + +export const useUserRole = () => { + const [userRole, setUserRole] = useState() + const { isUserLoggedIn, decodedIdToken } = useOidc(); + + useEffect(() => { + if (isUserLoggedIn && decodedIdToken) { + const rolesKeyName: string | undefined = Object.keys(decodedIdToken).find((key) => key.includes('roles')); + const idTokenRoles: string[] = decodedIdToken[rolesKeyName!] as string[]; + + let newUserRole: string | undefined; + + for (const key in UserRole) { + if (UserRole[key as keyof typeof UserRole] === idTokenRoles[0]) { + newUserRole = key; + + break; + } + } + + setUserRole(UserRole[newUserRole! as keyof typeof UserRole]); + } + }, [decodedIdToken, isUserLoggedIn]) + + return { + userRole + } +} \ No newline at end of file diff --git a/client/src/httpClient/httpClient.tsx b/client/src/httpClient/httpClient.tsx new file mode 100644 index 0000000..5d20716 --- /dev/null +++ b/client/src/httpClient/httpClient.tsx @@ -0,0 +1,25 @@ +import axios, { AxiosInstance, CreateAxiosDefaults } from 'axios'; +import { getOidc } from '../oidc'; + +let config: CreateAxiosDefaults = { + baseURL: import.meta.env.VITE_API_URL, + headers: { + 'Content-Type': 'application/json' + } +}; + +const httpClient: AxiosInstance = axios.create(config) + +httpClient.interceptors.request.use(async (config) => { + const oidc = await getOidc(); + + if (oidc.isUserLoggedIn) { + const accessToken = await oidc.getAccessToken(); + + config.headers.Authorization = `Bearer ${accessToken}`; + } + + return config; +}); + +export default httpClient; \ No newline at end of file diff --git a/client/src/interfaces.ts b/client/src/interfaces.ts new file mode 100644 index 0000000..5bbdf16 --- /dev/null +++ b/client/src/interfaces.ts @@ -0,0 +1,4 @@ +export interface Alert { + severity: 'success' | 'error' | 'info' | 'warning'; + message: string; +} \ No newline at end of file diff --git a/client/src/interfaces/Alert.interface.ts b/client/src/interfaces/Alert.interface.ts new file mode 100644 index 0000000..5cd28c4 --- /dev/null +++ b/client/src/interfaces/Alert.interface.ts @@ -0,0 +1,4 @@ +export interface Alert { + severity: 'info' | 'error' | 'success' | 'warning'; + message: string; +} \ No newline at end of file diff --git a/client/src/interfaces/Status.interface.ts b/client/src/interfaces/Status.interface.ts new file mode 100644 index 0000000..9024860 --- /dev/null +++ b/client/src/interfaces/Status.interface.ts @@ -0,0 +1,4 @@ +export interface Status { + id: number; + name: string; +} \ No newline at end of file diff --git a/client/src/main.tsx b/client/src/main.tsx new file mode 100644 index 0000000..38e26b2 --- /dev/null +++ b/client/src/main.tsx @@ -0,0 +1,18 @@ +import ReactDOM from 'react-dom/client' +import App from './App.tsx' +import { BrowserRouter } from 'react-router-dom'; +import AppContextProvider from './context/appContext/AppContextProvider.tsx'; +import './styles.css'; +import { OidcInitializationGate } from './oidc.ts'; + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + + + + + +) + + diff --git a/client/src/oidc.ts b/client/src/oidc.ts new file mode 100644 index 0000000..83af6a0 --- /dev/null +++ b/client/src/oidc.ts @@ -0,0 +1,21 @@ +import { oidcSpa } from "oidc-spa/react-spa"; + +export const { + bootstrapOidc, + useOidc, + getOidc, + withLoginEnforced, + OidcInitializationGate +} = oidcSpa.createUtils() + +bootstrapOidc({ + implementation: 'real', + issuerUri: import.meta.env.VITE_ISSUER_URI, + clientId: import.meta.env.VITE_CLIENT_ID, + scopes: [ + 'email', + 'openid', + 'profile', + `api://${import.meta.env.VITE_CLIENT_ID}/user_impersonation` + ] +}) \ No newline at end of file diff --git a/client/src/styles.css b/client/src/styles.css new file mode 100644 index 0000000..2e37768 --- /dev/null +++ b/client/src/styles.css @@ -0,0 +1,42 @@ +@import "tailwindcss"; +@plugin "daisyui"; +@plugin "daisyui/theme" { + name: "lofi"; + default: true; + prefersdark: false; + color-scheme: "light"; + --color-base-100: oklch(100% 0 0); + --color-base-200: oklch(97% 0 0); + --color-base-300: oklch(94% 0 0); + --color-base-content: oklch(0% 0 0); + --color-primary: oklch(15.906% 0 0); + --color-primary-content: oklch(100% 0 0); + --color-secondary: oklch(21.455% 0.001 17.278); + --color-secondary-content: oklch(100% 0 0); + --color-accent: oklch(26.861% 0 0); + --color-accent-content: oklch(100% 0 0); + --color-neutral: oklch(0% 0 0); + --color-neutral-content: oklch(100% 0 0); + --color-info: oklch(79.54% 0.103 205.9); + --color-info-content: oklch(15.908% 0.02 205.9); + --color-success: oklch(90.13% 0.153 164.14); + --color-success-content: oklch(18.026% 0.03 164.14); + --color-warning: oklch(88.37% 0.135 79.94); + --color-warning-content: oklch(17.674% 0.027 79.94); + --color-error: oklch(78.66% 0.15 28.47); + --color-error-content: oklch(15.732% 0.03 28.47); + --radius-selector: 0.5rem; + --radius-field: 0.5rem; + --radius-box: 0.5rem; + --size-selector: 0.25rem; + --size-field: 0.25rem; + --border: 1px; + --depth: 0; + --noise: 0; +} +@plugin "@tailwindcss/typography"; + +body { + background-color: #f5f5f5; + min-height: 100vh; +} \ No newline at end of file diff --git a/client/src/tanstack.d.ts b/client/src/tanstack.d.ts new file mode 100644 index 0000000..20aa39d --- /dev/null +++ b/client/src/tanstack.d.ts @@ -0,0 +1,11 @@ +import '@tanstack/react-table'; + +/* eslint-disable */ +declare module '@tanstack/react-table' { + interface ColumnMeta { + align?: 'text-left' | 'text-center' | 'text-right'; + className?: string; + headerAlign?: 'text-left' | 'text-center' | 'text-right'; + } +} +/* eslint-enable */ diff --git a/client/src/vite-env.d.ts b/client/src/vite-env.d.ts new file mode 100644 index 0000000..9bf12a3 --- /dev/null +++ b/client/src/vite-env.d.ts @@ -0,0 +1,12 @@ +/// + +interface ImportMetaEnv { + readonly VITE_API_URL: string; + readonly VITE_CLIENT_ID: string; + readonly VITE_TENANT_ID: string; + readonly VITE_REDIRECT_URL: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} \ No newline at end of file diff --git a/client/tsconfig.app.json b/client/tsconfig.app.json new file mode 100644 index 0000000..358ca9b --- /dev/null +++ b/client/tsconfig.app.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src"] +} diff --git a/client/tsconfig.json b/client/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/client/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/client/tsconfig.node.json b/client/tsconfig.node.json new file mode 100644 index 0000000..db0becc --- /dev/null +++ b/client/tsconfig.node.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2022", + "lib": ["ES2023"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/client/vite.config.ts b/client/vite.config.ts new file mode 100644 index 0000000..12f1cae --- /dev/null +++ b/client/vite.config.ts @@ -0,0 +1,29 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import tailwindcss from '@tailwindcss/vite'; +import { oidcSpa } from 'oidc-spa/vite-plugin' + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [ + oidcSpa(), + react(), + tailwindcss() + ], + preview: { + port: 8080, + strictPort: true + }, + server: { + host: true, + port: 8080, + proxy: { + '/api': { + target: 'http://localhost:3000', + changeOrigin: true, + secure: false, + rewrite: (path) => path.replace(/^\/api/, '') + } + } + } +}); diff --git a/database/litestream/litestream.yml b/database/litestream/litestream.yml new file mode 100644 index 0000000..6fd2a98 --- /dev/null +++ b/database/litestream/litestream.yml @@ -0,0 +1,4 @@ +dbs: + - path: /mnt/data/portfolio.db + replicas: + - path: /mnt/data/backup/portfolio.db \ No newline at end of file diff --git a/infrastructure/.terraform.lock.hcl b/infrastructure/.terraform.lock.hcl new file mode 100644 index 0000000..d0f144b --- /dev/null +++ b/infrastructure/.terraform.lock.hcl @@ -0,0 +1,61 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/azuread" { + version = "3.9.0" + hashes = [ + "h1:caKVAk5GOECNATz8XPruo39n2y6OcntxPblPgl+6QaY=", + "zh:1c3e89cf19118fc07d7b04257251fc9897e722c16e0a0df7b07fcd261f8c12e7", + "zh:39b11a075e4baa4f6ed5c72a8427013d50f43eecc1a7603b73bccf80f952f758", + "zh:41484c196c943b39411f561e70a308bd2a71da18155bfec7381ba0bd61361d34", + "zh:42068e5da223494beea5f7fcb9057c308cbfa92f96e53c50083e2639216479d8", + "zh:464d7da44682443a4b64bfdaf3d0eb53011c6e1471f244f6354c4d5bca18edce", + "zh:49f597ea3fac39931ff91e55afd5b5cc91e449920a03716f82509d588aaab708", + "zh:6092c376accfc50b555b7a0cd56b76c09abc3d65ac9dd5069063d6f9f1e76d3b", + "zh:65326a9f3ac0783c16e05c16422d191f0a926b8d021fd5303c1fdf8dc42f16e9", + "zh:784214ed809347d74562bb38194c0cef57831eaa621ba3b7cdd3fe7a7a76d844", + "zh:b4233f9bc791adc7d6643507fa5b47360a21125763a072d953586151cacb65f9", + "zh:c4ecdd995ff99b7e362e087c45f080816bcf097da5be257c87b912210e45dd3e", + "zh:f0122771f71cb98248e70cdd6c2ccd3bffb34e79d19897fa28b785c86b2312ed", + ] +} + +provider "registry.terraform.io/hashicorp/azurerm" { + version = "4.40.0" + constraints = "4.40.0" + hashes = [ + "h1:/TNkrn3b7z0ucgKX6Y3QqSedGxFJfrksNc482wOfQvc=", + "zh:035ff0ed9fe359e9ec36a79d22ce47eaa9d6b2ec6ba834d6d2fdec3d216d6b52", + "zh:1a22d900c453cca39e9a0c423a517593295a07619f52386565c6b96e6c9f6b27", + "zh:1f46331d0ef6fae6bae357e574e9d2a0b74986fd271c80d21e8b64b8a493d07c", + "zh:38f5cb8264c27962e64e41987d5e607591807c6db64f9f02c4b97f195ecc7c26", + "zh:64bbc0f4f19b38caa1d0590a1fc58800779a0d00396100040a118f91ec43c707", + "zh:6b1695c75ee5c9b632c1b80c4df56bc9a5c612e6952d563807492742c13dca49", + "zh:6d9038d784601386e731f5bbe82d9bf405b3b147158ca09c055ac3ed1632d14b", + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", + "zh:b5b80bf352a3fe130024a7bf68531549f0fc3c1b96945440942a851128b7bf8b", + "zh:ce82cc82a3f483e69dde55818e28be625d81c5ff2b881e13ab3a6cd4ed06ff04", + "zh:f4ad5295d44c612490d2ae25454f7220fc961d609abc82b1b9a8fd718691b060", + "zh:f6fd85bfca8516f9f3840acc3f0e5e335f6195b66231c2db05c0fb43dd8aebe1", + ] +} + +provider "registry.terraform.io/hashicorp/random" { + version = "3.9.0" + hashes = [ + "h1:OO+IuvQJSPmWdN8AyyIEvPJbLvDQpgX/zbktoa9KsJE=", + "zh:161ad0bd9a75768c82f53fb6e7172a9d8be2d4889b012645a34795031aaf1bf1", + "zh:19dc9a5b17729725ccfc4f45b0500af0ee5bc6b6b160c7adb8f2bf617d2c80ea", + "zh:269eda8fe42daa7974d5a34d166c3ba9defe80cde86c01e4dadcfdf2e1f05e5f", + "zh:373f7c65566f8f2cc7f45d698654feb9d988996957e1266a69ca00c52d6d16d0", + "zh:5599d16804c41c83009ec621b6d6b6f74e102f5827678a4750f8809055546b61", + "zh:583be0440469a22bff70dcfa56593b01566860b29607437264adb51060cf46fc", + "zh:5f211d8ec3f2e1f414870d9584bfe26e6995560ef81c748f8447a48164767398", + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", + "zh:7b547fd16216761ef86efc3ed516ac5ac0c5c42b7c7eb24a08cef2d93f69ed5e", + "zh:7e7c0679daf2a382151d05068c8c3f0dae6b7b7dccf818827b73dd08638df2ef", + "zh:8089dec888a8038b9b4fb23b3df7e1057293dbc5b60b42cc47ff690d69d4b61b", + "zh:c51f15a031edfd6f23ce8ced3446ca7f8d8d647e2499890d7d5d10d5016d7257", + "zh:c94784f005708890dc6895afd53636ec00ec1e430b15d41e5aebfb1d4b39bd04", + ] +} diff --git a/infrastructure/.terraform/modules/modules.json b/infrastructure/.terraform/modules/modules.json new file mode 100644 index 0000000..2cfab42 --- /dev/null +++ b/infrastructure/.terraform/modules/modules.json @@ -0,0 +1 @@ +{"Modules":[{"Key":"","Source":"","Dir":"."},{"Key":"environment","Source":"./config","Dir":"config"}]} \ No newline at end of file diff --git a/infrastructure/.terraform/providers/registry.terraform.io/hashicorp/azuread/3.9.0/darwin_arm64/LICENSE.txt b/infrastructure/.terraform/providers/registry.terraform.io/hashicorp/azuread/3.9.0/darwin_arm64/LICENSE.txt new file mode 100644 index 0000000..3b97eaf --- /dev/null +++ b/infrastructure/.terraform/providers/registry.terraform.io/hashicorp/azuread/3.9.0/darwin_arm64/LICENSE.txt @@ -0,0 +1,375 @@ +Copyright (c) 2019 HashiCorp, Inc. + +Mozilla Public License Version 2.0 +================================== + +1. Definitions +-------------- + +1.1. "Contributor" + means each individual or legal entity that creates, contributes to + the creation of, or owns Covered Software. + +1.2. "Contributor Version" + means the combination of the Contributions of others (if any) used + by a Contributor and that particular Contributor's Contribution. + +1.3. "Contribution" + means Covered Software of a particular Contributor. + +1.4. "Covered Software" + means Source Code Form to which the initial Contributor has attached + the notice in Exhibit A, the Executable Form of such Source Code + Form, and Modifications of such Source Code Form, in each case + including portions thereof. + +1.5. "Incompatible With Secondary Licenses" + means + + (a) that the initial Contributor has attached the notice described + in Exhibit B to the Covered Software; or + + (b) that the Covered Software was made available under the terms of + version 1.1 or earlier of the License, but not also under the + terms of a Secondary License. + +1.6. "Executable Form" + means any form of the work other than Source Code Form. + +1.7. "Larger Work" + means a work that combines Covered Software with other material, in + a separate file or files, that is not Covered Software. + +1.8. "License" + means this document. + +1.9. "Licensable" + means having the right to grant, to the maximum extent possible, + whether at the time of the initial grant or subsequently, any and + all of the rights conveyed by this License. + +1.10. "Modifications" + means any of the following: + + (a) any file in Source Code Form that results from an addition to, + deletion from, or modification of the contents of Covered + Software; or + + (b) any new file in Source Code Form that contains any Covered + Software. + +1.11. "Patent Claims" of a Contributor + means any patent claim(s), including without limitation, method, + process, and apparatus claims, in any patent Licensable by such + Contributor that would be infringed, but for the grant of the + License, by the making, using, selling, offering for sale, having + made, import, or transfer of either its Contributions or its + Contributor Version. + +1.12. "Secondary License" + means either the GNU General Public License, Version 2.0, the GNU + Lesser General Public License, Version 2.1, the GNU Affero General + Public License, Version 3.0, or any later versions of those + licenses. + +1.13. "Source Code Form" + means the form of the work preferred for making modifications. + +1.14. "You" (or "Your") + means an individual or a legal entity exercising rights under this + License. For legal entities, "You" includes any entity that + controls, is controlled by, or is under common control with You. For + purposes of this definition, "control" means (a) the power, direct + or indirect, to cause the direction or management of such entity, + whether by contract or otherwise, or (b) ownership of more than + fifty percent (50%) of the outstanding shares or beneficial + ownership of such entity. + +2. License Grants and Conditions +-------------------------------- + +2.1. Grants + +Each Contributor hereby grants You a world-wide, royalty-free, +non-exclusive license: + +(a) under intellectual property rights (other than patent or trademark) + Licensable by such Contributor to use, reproduce, make available, + modify, display, perform, distribute, and otherwise exploit its + Contributions, either on an unmodified basis, with Modifications, or + as part of a Larger Work; and + +(b) under Patent Claims of such Contributor to make, use, sell, offer + for sale, have made, import, and otherwise transfer either its + Contributions or its Contributor Version. + +2.2. Effective Date + +The licenses granted in Section 2.1 with respect to any Contribution +become effective for each Contribution on the date the Contributor first +distributes such Contribution. + +2.3. Limitations on Grant Scope + +The licenses granted in this Section 2 are the only rights granted under +this License. No additional rights or licenses will be implied from the +distribution or licensing of Covered Software under this License. +Notwithstanding Section 2.1(b) above, no patent license is granted by a +Contributor: + +(a) for any code that a Contributor has removed from Covered Software; + or + +(b) for infringements caused by: (i) Your and any other third party's + modifications of Covered Software, or (ii) the combination of its + Contributions with other software (except as part of its Contributor + Version); or + +(c) under Patent Claims infringed by Covered Software in the absence of + its Contributions. + +This License does not grant any rights in the trademarks, service marks, +or logos of any Contributor (except as may be necessary to comply with +the notice requirements in Section 3.4). + +2.4. Subsequent Licenses + +No Contributor makes additional grants as a result of Your choice to +distribute the Covered Software under a subsequent version of this +License (see Section 10.2) or under the terms of a Secondary License (if +permitted under the terms of Section 3.3). + +2.5. Representation + +Each Contributor represents that the Contributor believes its +Contributions are its original creation(s) or it has sufficient rights +to grant the rights to its Contributions conveyed by this License. + +2.6. Fair Use + +This License is not intended to limit any rights You have under +applicable copyright doctrines of fair use, fair dealing, or other +equivalents. + +2.7. Conditions + +Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted +in Section 2.1. + +3. Responsibilities +------------------- + +3.1. Distribution of Source Form + +All distribution of Covered Software in Source Code Form, including any +Modifications that You create or to which You contribute, must be under +the terms of this License. You must inform recipients that the Source +Code Form of the Covered Software is governed by the terms of this +License, and how they can obtain a copy of this License. You may not +attempt to alter or restrict the recipients' rights in the Source Code +Form. + +3.2. Distribution of Executable Form + +If You distribute Covered Software in Executable Form then: + +(a) such Covered Software must also be made available in Source Code + Form, as described in Section 3.1, and You must inform recipients of + the Executable Form how they can obtain a copy of such Source Code + Form by reasonable means in a timely manner, at a charge no more + than the cost of distribution to the recipient; and + +(b) You may distribute such Executable Form under the terms of this + License, or sublicense it under different terms, provided that the + license for the Executable Form does not attempt to limit or alter + the recipients' rights in the Source Code Form under this License. + +3.3. Distribution of a Larger Work + +You may create and distribute a Larger Work under terms of Your choice, +provided that You also comply with the requirements of this License for +the Covered Software. If the Larger Work is a combination of Covered +Software with a work governed by one or more Secondary Licenses, and the +Covered Software is not Incompatible With Secondary Licenses, this +License permits You to additionally distribute such Covered Software +under the terms of such Secondary License(s), so that the recipient of +the Larger Work may, at their option, further distribute the Covered +Software under the terms of either this License or such Secondary +License(s). + +3.4. Notices + +You may not remove or alter the substance of any license notices +(including copyright notices, patent notices, disclaimers of warranty, +or limitations of liability) contained within the Source Code Form of +the Covered Software, except that You may alter any license notices to +the extent required to remedy known factual inaccuracies. + +3.5. Application of Additional Terms + +You may choose to offer, and to charge a fee for, warranty, support, +indemnity or liability obligations to one or more recipients of Covered +Software. However, You may do so only on Your own behalf, and not on +behalf of any Contributor. You must make it absolutely clear that any +such warranty, support, indemnity, or liability obligation is offered by +You alone, and You hereby agree to indemnify every Contributor for any +liability incurred by such Contributor as a result of warranty, support, +indemnity or liability terms You offer. You may include additional +disclaimers of warranty and limitations of liability specific to any +jurisdiction. + +4. Inability to Comply Due to Statute or Regulation +--------------------------------------------------- + +If it is impossible for You to comply with any of the terms of this +License with respect to some or all of the Covered Software due to +statute, judicial order, or regulation then You must: (a) comply with +the terms of this License to the maximum extent possible; and (b) +describe the limitations and the code they affect. Such description must +be placed in a text file included with all distributions of the Covered +Software under this License. Except to the extent prohibited by statute +or regulation, such description must be sufficiently detailed for a +recipient of ordinary skill to be able to understand it. + +5. Termination +-------------- + +5.1. The rights granted under this License will terminate automatically +if You fail to comply with any of its terms. However, if You become +compliant, then the rights granted under this License from a particular +Contributor are reinstated (a) provisionally, unless and until such +Contributor explicitly and finally terminates Your grants, and (b) on an +ongoing basis, if such Contributor fails to notify You of the +non-compliance by some reasonable means prior to 60 days after You have +come back into compliance. Moreover, Your grants from a particular +Contributor are reinstated on an ongoing basis if such Contributor +notifies You of the non-compliance by some reasonable means, this is the +first time You have received notice of non-compliance with this License +from such Contributor, and You become compliant prior to 30 days after +Your receipt of the notice. + +5.2. If You initiate litigation against any entity by asserting a patent +infringement claim (excluding declaratory judgment actions, +counter-claims, and cross-claims) alleging that a Contributor Version +directly or indirectly infringes any patent, then the rights granted to +You by any and all Contributors for the Covered Software under Section +2.1 of this License shall terminate. + +5.3. In the event of termination under Sections 5.1 or 5.2 above, all +end user license agreements (excluding distributors and resellers) which +have been validly granted by You or Your distributors under this License +prior to termination shall survive termination. + +************************************************************************ +* * +* 6. Disclaimer of Warranty * +* ------------------------- * +* * +* Covered Software is provided under this License on an "as is" * +* basis, without warranty of any kind, either expressed, implied, or * +* statutory, including, without limitation, warranties that the * +* Covered Software is free of defects, merchantable, fit for a * +* particular purpose or non-infringing. The entire risk as to the * +* quality and performance of the Covered Software is with You. * +* Should any Covered Software prove defective in any respect, You * +* (not any Contributor) assume the cost of any necessary servicing, * +* repair, or correction. This disclaimer of warranty constitutes an * +* essential part of this License. No use of any Covered Software is * +* authorized under this License except under this disclaimer. * +* * +************************************************************************ + +************************************************************************ +* * +* 7. Limitation of Liability * +* -------------------------- * +* * +* Under no circumstances and under no legal theory, whether tort * +* (including negligence), contract, or otherwise, shall any * +* Contributor, or anyone who distributes Covered Software as * +* permitted above, be liable to You for any direct, indirect, * +* special, incidental, or consequential damages of any character * +* including, without limitation, damages for lost profits, loss of * +* goodwill, work stoppage, computer failure or malfunction, or any * +* and all other commercial damages or losses, even if such party * +* shall have been informed of the possibility of such damages. This * +* limitation of liability shall not apply to liability for death or * +* personal injury resulting from such party's negligence to the * +* extent applicable law prohibits such limitation. Some * +* jurisdictions do not allow the exclusion or limitation of * +* incidental or consequential damages, so this exclusion and * +* limitation may not apply to You. * +* * +************************************************************************ + +8. Litigation +------------- + +Any litigation relating to this License may be brought only in the +courts of a jurisdiction where the defendant maintains its principal +place of business and such litigation shall be governed by laws of that +jurisdiction, without reference to its conflict-of-law provisions. +Nothing in this Section shall prevent a party's ability to bring +cross-claims or counter-claims. + +9. Miscellaneous +---------------- + +This License represents the complete agreement concerning the subject +matter hereof. If any provision of this License is held to be +unenforceable, such provision shall be reformed only to the extent +necessary to make it enforceable. Any law or regulation which provides +that the language of a contract shall be construed against the drafter +shall not be used to construe this License against a Contributor. + +10. Versions of the License +--------------------------- + +10.1. New Versions + +Mozilla Foundation is the license steward. Except as provided in Section +10.3, no one other than the license steward has the right to modify or +publish new versions of this License. Each version will be given a +distinguishing version number. + +10.2. Effect of New Versions + +You may distribute the Covered Software under the terms of the version +of the License under which You originally received the Covered Software, +or under the terms of any subsequent version published by the license +steward. + +10.3. Modified Versions + +If you create software not governed by this License, and you want to +create a new license for such software, you may create and use a +modified version of this License if you rename the license and remove +any references to the name of the license steward (except to note that +such modified license differs from this License). + +10.4. Distributing Source Code Form that is Incompatible With Secondary +Licenses + +If You choose to distribute Source Code Form that is Incompatible With +Secondary Licenses under the terms of this version of the License, the +notice described in Exhibit B of this License must be attached. + +Exhibit A - Source Code Form License Notice +------------------------------------------- + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at http://mozilla.org/MPL/2.0/. + +If it is not possible or desirable to put the notice in a particular +file, then You may include the notice in a location (such as a LICENSE +file in a relevant directory) where a recipient would be likely to look +for such a notice. + +You may add additional accurate notices of copyright ownership. + +Exhibit B - "Incompatible With Secondary Licenses" Notice +--------------------------------------------------------- + + This Source Code Form is "Incompatible With Secondary Licenses", as + defined by the Mozilla Public License, v. 2.0. diff --git a/infrastructure/.terraform/providers/registry.terraform.io/hashicorp/azuread/3.9.0/darwin_arm64/terraform-provider-azuread_v3.9.0_x5 b/infrastructure/.terraform/providers/registry.terraform.io/hashicorp/azuread/3.9.0/darwin_arm64/terraform-provider-azuread_v3.9.0_x5 new file mode 100755 index 0000000..2df79c5 Binary files /dev/null and b/infrastructure/.terraform/providers/registry.terraform.io/hashicorp/azuread/3.9.0/darwin_arm64/terraform-provider-azuread_v3.9.0_x5 differ diff --git a/infrastructure/.terraform/providers/registry.terraform.io/hashicorp/azurerm/4.40.0/darwin_arm64/LICENSE.txt b/infrastructure/.terraform/providers/registry.terraform.io/hashicorp/azurerm/4.40.0/darwin_arm64/LICENSE.txt new file mode 100644 index 0000000..b9ac071 --- /dev/null +++ b/infrastructure/.terraform/providers/registry.terraform.io/hashicorp/azurerm/4.40.0/darwin_arm64/LICENSE.txt @@ -0,0 +1,375 @@ +Copyright (c) 2017 HashiCorp, Inc. + +Mozilla Public License Version 2.0 +================================== + +1. Definitions +-------------- + +1.1. "Contributor" + means each individual or legal entity that creates, contributes to + the creation of, or owns Covered Software. + +1.2. "Contributor Version" + means the combination of the Contributions of others (if any) used + by a Contributor and that particular Contributor's Contribution. + +1.3. "Contribution" + means Covered Software of a particular Contributor. + +1.4. "Covered Software" + means Source Code Form to which the initial Contributor has attached + the notice in Exhibit A, the Executable Form of such Source Code + Form, and Modifications of such Source Code Form, in each case + including portions thereof. + +1.5. "Incompatible With Secondary Licenses" + means + + (a) that the initial Contributor has attached the notice described + in Exhibit B to the Covered Software; or + + (b) that the Covered Software was made available under the terms of + version 1.1 or earlier of the License, but not also under the + terms of a Secondary License. + +1.6. "Executable Form" + means any form of the work other than Source Code Form. + +1.7. "Larger Work" + means a work that combines Covered Software with other material, in + a separate file or files, that is not Covered Software. + +1.8. "License" + means this document. + +1.9. "Licensable" + means having the right to grant, to the maximum extent possible, + whether at the time of the initial grant or subsequently, any and + all of the rights conveyed by this License. + +1.10. "Modifications" + means any of the following: + + (a) any file in Source Code Form that results from an addition to, + deletion from, or modification of the contents of Covered + Software; or + + (b) any new file in Source Code Form that contains any Covered + Software. + +1.11. "Patent Claims" of a Contributor + means any patent claim(s), including without limitation, method, + process, and apparatus claims, in any patent Licensable by such + Contributor that would be infringed, but for the grant of the + License, by the making, using, selling, offering for sale, having + made, import, or transfer of either its Contributions or its + Contributor Version. + +1.12. "Secondary License" + means either the GNU General Public License, Version 2.0, the GNU + Lesser General Public License, Version 2.1, the GNU Affero General + Public License, Version 3.0, or any later versions of those + licenses. + +1.13. "Source Code Form" + means the form of the work preferred for making modifications. + +1.14. "You" (or "Your") + means an individual or a legal entity exercising rights under this + License. For legal entities, "You" includes any entity that + controls, is controlled by, or is under common control with You. For + purposes of this definition, "control" means (a) the power, direct + or indirect, to cause the direction or management of such entity, + whether by contract or otherwise, or (b) ownership of more than + fifty percent (50%) of the outstanding shares or beneficial + ownership of such entity. + +2. License Grants and Conditions +-------------------------------- + +2.1. Grants + +Each Contributor hereby grants You a world-wide, royalty-free, +non-exclusive license: + +(a) under intellectual property rights (other than patent or trademark) + Licensable by such Contributor to use, reproduce, make available, + modify, display, perform, distribute, and otherwise exploit its + Contributions, either on an unmodified basis, with Modifications, or + as part of a Larger Work; and + +(b) under Patent Claims of such Contributor to make, use, sell, offer + for sale, have made, import, and otherwise transfer either its + Contributions or its Contributor Version. + +2.2. Effective Date + +The licenses granted in Section 2.1 with respect to any Contribution +become effective for each Contribution on the date the Contributor first +distributes such Contribution. + +2.3. Limitations on Grant Scope + +The licenses granted in this Section 2 are the only rights granted under +this License. No additional rights or licenses will be implied from the +distribution or licensing of Covered Software under this License. +Notwithstanding Section 2.1(b) above, no patent license is granted by a +Contributor: + +(a) for any code that a Contributor has removed from Covered Software; + or + +(b) for infringements caused by: (i) Your and any other third party's + modifications of Covered Software, or (ii) the combination of its + Contributions with other software (except as part of its Contributor + Version); or + +(c) under Patent Claims infringed by Covered Software in the absence of + its Contributions. + +This License does not grant any rights in the trademarks, service marks, +or logos of any Contributor (except as may be necessary to comply with +the notice requirements in Section 3.4). + +2.4. Subsequent Licenses + +No Contributor makes additional grants as a result of Your choice to +distribute the Covered Software under a subsequent version of this +License (see Section 10.2) or under the terms of a Secondary License (if +permitted under the terms of Section 3.3). + +2.5. Representation + +Each Contributor represents that the Contributor believes its +Contributions are its original creation(s) or it has sufficient rights +to grant the rights to its Contributions conveyed by this License. + +2.6. Fair Use + +This License is not intended to limit any rights You have under +applicable copyright doctrines of fair use, fair dealing, or other +equivalents. + +2.7. Conditions + +Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted +in Section 2.1. + +3. Responsibilities +------------------- + +3.1. Distribution of Source Form + +All distribution of Covered Software in Source Code Form, including any +Modifications that You create or to which You contribute, must be under +the terms of this License. You must inform recipients that the Source +Code Form of the Covered Software is governed by the terms of this +License, and how they can obtain a copy of this License. You may not +attempt to alter or restrict the recipients' rights in the Source Code +Form. + +3.2. Distribution of Executable Form + +If You distribute Covered Software in Executable Form then: + +(a) such Covered Software must also be made available in Source Code + Form, as described in Section 3.1, and You must inform recipients of + the Executable Form how they can obtain a copy of such Source Code + Form by reasonable means in a timely manner, at a charge no more + than the cost of distribution to the recipient; and + +(b) You may distribute such Executable Form under the terms of this + License, or sublicense it under different terms, provided that the + license for the Executable Form does not attempt to limit or alter + the recipients' rights in the Source Code Form under this License. + +3.3. Distribution of a Larger Work + +You may create and distribute a Larger Work under terms of Your choice, +provided that You also comply with the requirements of this License for +the Covered Software. If the Larger Work is a combination of Covered +Software with a work governed by one or more Secondary Licenses, and the +Covered Software is not Incompatible With Secondary Licenses, this +License permits You to additionally distribute such Covered Software +under the terms of such Secondary License(s), so that the recipient of +the Larger Work may, at their option, further distribute the Covered +Software under the terms of either this License or such Secondary +License(s). + +3.4. Notices + +You may not remove or alter the substance of any license notices +(including copyright notices, patent notices, disclaimers of warranty, +or limitations of liability) contained within the Source Code Form of +the Covered Software, except that You may alter any license notices to +the extent required to remedy known factual inaccuracies. + +3.5. Application of Additional Terms + +You may choose to offer, and to charge a fee for, warranty, support, +indemnity or liability obligations to one or more recipients of Covered +Software. However, You may do so only on Your own behalf, and not on +behalf of any Contributor. You must make it absolutely clear that any +such warranty, support, indemnity, or liability obligation is offered by +You alone, and You hereby agree to indemnify every Contributor for any +liability incurred by such Contributor as a result of warranty, support, +indemnity or liability terms You offer. You may include additional +disclaimers of warranty and limitations of liability specific to any +jurisdiction. + +4. Inability to Comply Due to Statute or Regulation +--------------------------------------------------- + +If it is impossible for You to comply with any of the terms of this +License with respect to some or all of the Covered Software due to +statute, judicial order, or regulation then You must: (a) comply with +the terms of this License to the maximum extent possible; and (b) +describe the limitations and the code they affect. Such description must +be placed in a text file included with all distributions of the Covered +Software under this License. Except to the extent prohibited by statute +or regulation, such description must be sufficiently detailed for a +recipient of ordinary skill to be able to understand it. + +5. Termination +-------------- + +5.1. The rights granted under this License will terminate automatically +if You fail to comply with any of its terms. However, if You become +compliant, then the rights granted under this License from a particular +Contributor are reinstated (a) provisionally, unless and until such +Contributor explicitly and finally terminates Your grants, and (b) on an +ongoing basis, if such Contributor fails to notify You of the +non-compliance by some reasonable means prior to 60 days after You have +come back into compliance. Moreover, Your grants from a particular +Contributor are reinstated on an ongoing basis if such Contributor +notifies You of the non-compliance by some reasonable means, this is the +first time You have received notice of non-compliance with this License +from such Contributor, and You become compliant prior to 30 days after +Your receipt of the notice. + +5.2. If You initiate litigation against any entity by asserting a patent +infringement claim (excluding declaratory judgment actions, +counter-claims, and cross-claims) alleging that a Contributor Version +directly or indirectly infringes any patent, then the rights granted to +You by any and all Contributors for the Covered Software under Section +2.1 of this License shall terminate. + +5.3. In the event of termination under Sections 5.1 or 5.2 above, all +end user license agreements (excluding distributors and resellers) which +have been validly granted by You or Your distributors under this License +prior to termination shall survive termination. + +************************************************************************ +* * +* 6. Disclaimer of Warranty * +* ------------------------- * +* * +* Covered Software is provided under this License on an "as is" * +* basis, without warranty of any kind, either expressed, implied, or * +* statutory, including, without limitation, warranties that the * +* Covered Software is free of defects, merchantable, fit for a * +* particular purpose or non-infringing. The entire risk as to the * +* quality and performance of the Covered Software is with You. * +* Should any Covered Software prove defective in any respect, You * +* (not any Contributor) assume the cost of any necessary servicing, * +* repair, or correction. This disclaimer of warranty constitutes an * +* essential part of this License. No use of any Covered Software is * +* authorized under this License except under this disclaimer. * +* * +************************************************************************ + +************************************************************************ +* * +* 7. Limitation of Liability * +* -------------------------- * +* * +* Under no circumstances and under no legal theory, whether tort * +* (including negligence), contract, or otherwise, shall any * +* Contributor, or anyone who distributes Covered Software as * +* permitted above, be liable to You for any direct, indirect, * +* special, incidental, or consequential damages of any character * +* including, without limitation, damages for lost profits, loss of * +* goodwill, work stoppage, computer failure or malfunction, or any * +* and all other commercial damages or losses, even if such party * +* shall have been informed of the possibility of such damages. This * +* limitation of liability shall not apply to liability for death or * +* personal injury resulting from such party's negligence to the * +* extent applicable law prohibits such limitation. Some * +* jurisdictions do not allow the exclusion or limitation of * +* incidental or consequential damages, so this exclusion and * +* limitation may not apply to You. * +* * +************************************************************************ + +8. Litigation +------------- + +Any litigation relating to this License may be brought only in the +courts of a jurisdiction where the defendant maintains its principal +place of business and such litigation shall be governed by laws of that +jurisdiction, without reference to its conflict-of-law provisions. +Nothing in this Section shall prevent a party's ability to bring +cross-claims or counter-claims. + +9. Miscellaneous +---------------- + +This License represents the complete agreement concerning the subject +matter hereof. If any provision of this License is held to be +unenforceable, such provision shall be reformed only to the extent +necessary to make it enforceable. Any law or regulation which provides +that the language of a contract shall be construed against the drafter +shall not be used to construe this License against a Contributor. + +10. Versions of the License +--------------------------- + +10.1. New Versions + +Mozilla Foundation is the license steward. Except as provided in Section +10.3, no one other than the license steward has the right to modify or +publish new versions of this License. Each version will be given a +distinguishing version number. + +10.2. Effect of New Versions + +You may distribute the Covered Software under the terms of the version +of the License under which You originally received the Covered Software, +or under the terms of any subsequent version published by the license +steward. + +10.3. Modified Versions + +If you create software not governed by this License, and you want to +create a new license for such software, you may create and use a +modified version of this License if you rename the license and remove +any references to the name of the license steward (except to note that +such modified license differs from this License). + +10.4. Distributing Source Code Form that is Incompatible With Secondary +Licenses + +If You choose to distribute Source Code Form that is Incompatible With +Secondary Licenses under the terms of this version of the License, the +notice described in Exhibit B of this License must be attached. + +Exhibit A - Source Code Form License Notice +------------------------------------------- + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at http://mozilla.org/MPL/2.0/. + +If it is not possible or desirable to put the notice in a particular +file, then You may include the notice in a location (such as a LICENSE +file in a relevant directory) where a recipient would be likely to look +for such a notice. + +You may add additional accurate notices of copyright ownership. + +Exhibit B - "Incompatible With Secondary Licenses" Notice +--------------------------------------------------------- + + This Source Code Form is "Incompatible With Secondary Licenses", as + defined by the Mozilla Public License, v. 2.0. diff --git a/infrastructure/.terraform/providers/registry.terraform.io/hashicorp/azurerm/4.40.0/darwin_arm64/terraform-provider-azurerm_v4.40.0_x5 b/infrastructure/.terraform/providers/registry.terraform.io/hashicorp/azurerm/4.40.0/darwin_arm64/terraform-provider-azurerm_v4.40.0_x5 new file mode 100755 index 0000000..ac4212e Binary files /dev/null and b/infrastructure/.terraform/providers/registry.terraform.io/hashicorp/azurerm/4.40.0/darwin_arm64/terraform-provider-azurerm_v4.40.0_x5 differ diff --git a/infrastructure/.terraform/providers/registry.terraform.io/hashicorp/random/3.9.0/darwin_arm64/LICENSE.txt b/infrastructure/.terraform/providers/registry.terraform.io/hashicorp/random/3.9.0/darwin_arm64/LICENSE.txt new file mode 100644 index 0000000..766add2 --- /dev/null +++ b/infrastructure/.terraform/providers/registry.terraform.io/hashicorp/random/3.9.0/darwin_arm64/LICENSE.txt @@ -0,0 +1,375 @@ +Copyright IBM Corp. 2017, 2026 + +Mozilla Public License Version 2.0 +================================== + +1. Definitions +-------------- + +1.1. "Contributor" + means each individual or legal entity that creates, contributes to + the creation of, or owns Covered Software. + +1.2. "Contributor Version" + means the combination of the Contributions of others (if any) used + by a Contributor and that particular Contributor's Contribution. + +1.3. "Contribution" + means Covered Software of a particular Contributor. + +1.4. "Covered Software" + means Source Code Form to which the initial Contributor has attached + the notice in Exhibit A, the Executable Form of such Source Code + Form, and Modifications of such Source Code Form, in each case + including portions thereof. + +1.5. "Incompatible With Secondary Licenses" + means + + (a) that the initial Contributor has attached the notice described + in Exhibit B to the Covered Software; or + + (b) that the Covered Software was made available under the terms of + version 1.1 or earlier of the License, but not also under the + terms of a Secondary License. + +1.6. "Executable Form" + means any form of the work other than Source Code Form. + +1.7. "Larger Work" + means a work that combines Covered Software with other material, in + a separate file or files, that is not Covered Software. + +1.8. "License" + means this document. + +1.9. "Licensable" + means having the right to grant, to the maximum extent possible, + whether at the time of the initial grant or subsequently, any and + all of the rights conveyed by this License. + +1.10. "Modifications" + means any of the following: + + (a) any file in Source Code Form that results from an addition to, + deletion from, or modification of the contents of Covered + Software; or + + (b) any new file in Source Code Form that contains any Covered + Software. + +1.11. "Patent Claims" of a Contributor + means any patent claim(s), including without limitation, method, + process, and apparatus claims, in any patent Licensable by such + Contributor that would be infringed, but for the grant of the + License, by the making, using, selling, offering for sale, having + made, import, or transfer of either its Contributions or its + Contributor Version. + +1.12. "Secondary License" + means either the GNU General Public License, Version 2.0, the GNU + Lesser General Public License, Version 2.1, the GNU Affero General + Public License, Version 3.0, or any later versions of those + licenses. + +1.13. "Source Code Form" + means the form of the work preferred for making modifications. + +1.14. "You" (or "Your") + means an individual or a legal entity exercising rights under this + License. For legal entities, "You" includes any entity that + controls, is controlled by, or is under common control with You. For + purposes of this definition, "control" means (a) the power, direct + or indirect, to cause the direction or management of such entity, + whether by contract or otherwise, or (b) ownership of more than + fifty percent (50%) of the outstanding shares or beneficial + ownership of such entity. + +2. License Grants and Conditions +-------------------------------- + +2.1. Grants + +Each Contributor hereby grants You a world-wide, royalty-free, +non-exclusive license: + +(a) under intellectual property rights (other than patent or trademark) + Licensable by such Contributor to use, reproduce, make available, + modify, display, perform, distribute, and otherwise exploit its + Contributions, either on an unmodified basis, with Modifications, or + as part of a Larger Work; and + +(b) under Patent Claims of such Contributor to make, use, sell, offer + for sale, have made, import, and otherwise transfer either its + Contributions or its Contributor Version. + +2.2. Effective Date + +The licenses granted in Section 2.1 with respect to any Contribution +become effective for each Contribution on the date the Contributor first +distributes such Contribution. + +2.3. Limitations on Grant Scope + +The licenses granted in this Section 2 are the only rights granted under +this License. No additional rights or licenses will be implied from the +distribution or licensing of Covered Software under this License. +Notwithstanding Section 2.1(b) above, no patent license is granted by a +Contributor: + +(a) for any code that a Contributor has removed from Covered Software; + or + +(b) for infringements caused by: (i) Your and any other third party's + modifications of Covered Software, or (ii) the combination of its + Contributions with other software (except as part of its Contributor + Version); or + +(c) under Patent Claims infringed by Covered Software in the absence of + its Contributions. + +This License does not grant any rights in the trademarks, service marks, +or logos of any Contributor (except as may be necessary to comply with +the notice requirements in Section 3.4). + +2.4. Subsequent Licenses + +No Contributor makes additional grants as a result of Your choice to +distribute the Covered Software under a subsequent version of this +License (see Section 10.2) or under the terms of a Secondary License (if +permitted under the terms of Section 3.3). + +2.5. Representation + +Each Contributor represents that the Contributor believes its +Contributions are its original creation(s) or it has sufficient rights +to grant the rights to its Contributions conveyed by this License. + +2.6. Fair Use + +This License is not intended to limit any rights You have under +applicable copyright doctrines of fair use, fair dealing, or other +equivalents. + +2.7. Conditions + +Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted +in Section 2.1. + +3. Responsibilities +------------------- + +3.1. Distribution of Source Form + +All distribution of Covered Software in Source Code Form, including any +Modifications that You create or to which You contribute, must be under +the terms of this License. You must inform recipients that the Source +Code Form of the Covered Software is governed by the terms of this +License, and how they can obtain a copy of this License. You may not +attempt to alter or restrict the recipients' rights in the Source Code +Form. + +3.2. Distribution of Executable Form + +If You distribute Covered Software in Executable Form then: + +(a) such Covered Software must also be made available in Source Code + Form, as described in Section 3.1, and You must inform recipients of + the Executable Form how they can obtain a copy of such Source Code + Form by reasonable means in a timely manner, at a charge no more + than the cost of distribution to the recipient; and + +(b) You may distribute such Executable Form under the terms of this + License, or sublicense it under different terms, provided that the + license for the Executable Form does not attempt to limit or alter + the recipients' rights in the Source Code Form under this License. + +3.3. Distribution of a Larger Work + +You may create and distribute a Larger Work under terms of Your choice, +provided that You also comply with the requirements of this License for +the Covered Software. If the Larger Work is a combination of Covered +Software with a work governed by one or more Secondary Licenses, and the +Covered Software is not Incompatible With Secondary Licenses, this +License permits You to additionally distribute such Covered Software +under the terms of such Secondary License(s), so that the recipient of +the Larger Work may, at their option, further distribute the Covered +Software under the terms of either this License or such Secondary +License(s). + +3.4. Notices + +You may not remove or alter the substance of any license notices +(including copyright notices, patent notices, disclaimers of warranty, +or limitations of liability) contained within the Source Code Form of +the Covered Software, except that You may alter any license notices to +the extent required to remedy known factual inaccuracies. + +3.5. Application of Additional Terms + +You may choose to offer, and to charge a fee for, warranty, support, +indemnity or liability obligations to one or more recipients of Covered +Software. However, You may do so only on Your own behalf, and not on +behalf of any Contributor. You must make it absolutely clear that any +such warranty, support, indemnity, or liability obligation is offered by +You alone, and You hereby agree to indemnify every Contributor for any +liability incurred by such Contributor as a result of warranty, support, +indemnity or liability terms You offer. You may include additional +disclaimers of warranty and limitations of liability specific to any +jurisdiction. + +4. Inability to Comply Due to Statute or Regulation +--------------------------------------------------- + +If it is impossible for You to comply with any of the terms of this +License with respect to some or all of the Covered Software due to +statute, judicial order, or regulation then You must: (a) comply with +the terms of this License to the maximum extent possible; and (b) +describe the limitations and the code they affect. Such description must +be placed in a text file included with all distributions of the Covered +Software under this License. Except to the extent prohibited by statute +or regulation, such description must be sufficiently detailed for a +recipient of ordinary skill to be able to understand it. + +5. Termination +-------------- + +5.1. The rights granted under this License will terminate automatically +if You fail to comply with any of its terms. However, if You become +compliant, then the rights granted under this License from a particular +Contributor are reinstated (a) provisionally, unless and until such +Contributor explicitly and finally terminates Your grants, and (b) on an +ongoing basis, if such Contributor fails to notify You of the +non-compliance by some reasonable means prior to 60 days after You have +come back into compliance. Moreover, Your grants from a particular +Contributor are reinstated on an ongoing basis if such Contributor +notifies You of the non-compliance by some reasonable means, this is the +first time You have received notice of non-compliance with this License +from such Contributor, and You become compliant prior to 30 days after +Your receipt of the notice. + +5.2. If You initiate litigation against any entity by asserting a patent +infringement claim (excluding declaratory judgment actions, +counter-claims, and cross-claims) alleging that a Contributor Version +directly or indirectly infringes any patent, then the rights granted to +You by any and all Contributors for the Covered Software under Section +2.1 of this License shall terminate. + +5.3. In the event of termination under Sections 5.1 or 5.2 above, all +end user license agreements (excluding distributors and resellers) which +have been validly granted by You or Your distributors under this License +prior to termination shall survive termination. + +************************************************************************ +* * +* 6. Disclaimer of Warranty * +* ------------------------- * +* * +* Covered Software is provided under this License on an "as is" * +* basis, without warranty of any kind, either expressed, implied, or * +* statutory, including, without limitation, warranties that the * +* Covered Software is free of defects, merchantable, fit for a * +* particular purpose or non-infringing. The entire risk as to the * +* quality and performance of the Covered Software is with You. * +* Should any Covered Software prove defective in any respect, You * +* (not any Contributor) assume the cost of any necessary servicing, * +* repair, or correction. This disclaimer of warranty constitutes an * +* essential part of this License. No use of any Covered Software is * +* authorized under this License except under this disclaimer. * +* * +************************************************************************ + +************************************************************************ +* * +* 7. Limitation of Liability * +* -------------------------- * +* * +* Under no circumstances and under no legal theory, whether tort * +* (including negligence), contract, or otherwise, shall any * +* Contributor, or anyone who distributes Covered Software as * +* permitted above, be liable to You for any direct, indirect, * +* special, incidental, or consequential damages of any character * +* including, without limitation, damages for lost profits, loss of * +* goodwill, work stoppage, computer failure or malfunction, or any * +* and all other commercial damages or losses, even if such party * +* shall have been informed of the possibility of such damages. This * +* limitation of liability shall not apply to liability for death or * +* personal injury resulting from such party's negligence to the * +* extent applicable law prohibits such limitation. Some * +* jurisdictions do not allow the exclusion or limitation of * +* incidental or consequential damages, so this exclusion and * +* limitation may not apply to You. * +* * +************************************************************************ + +8. Litigation +------------- + +Any litigation relating to this License may be brought only in the +courts of a jurisdiction where the defendant maintains its principal +place of business and such litigation shall be governed by laws of that +jurisdiction, without reference to its conflict-of-law provisions. +Nothing in this Section shall prevent a party's ability to bring +cross-claims or counter-claims. + +9. Miscellaneous +---------------- + +This License represents the complete agreement concerning the subject +matter hereof. If any provision of this License is held to be +unenforceable, such provision shall be reformed only to the extent +necessary to make it enforceable. Any law or regulation which provides +that the language of a contract shall be construed against the drafter +shall not be used to construe this License against a Contributor. + +10. Versions of the License +--------------------------- + +10.1. New Versions + +Mozilla Foundation is the license steward. Except as provided in Section +10.3, no one other than the license steward has the right to modify or +publish new versions of this License. Each version will be given a +distinguishing version number. + +10.2. Effect of New Versions + +You may distribute the Covered Software under the terms of the version +of the License under which You originally received the Covered Software, +or under the terms of any subsequent version published by the license +steward. + +10.3. Modified Versions + +If you create software not governed by this License, and you want to +create a new license for such software, you may create and use a +modified version of this License if you rename the license and remove +any references to the name of the license steward (except to note that +such modified license differs from this License). + +10.4. Distributing Source Code Form that is Incompatible With Secondary +Licenses + +If You choose to distribute Source Code Form that is Incompatible With +Secondary Licenses under the terms of this version of the License, the +notice described in Exhibit B of this License must be attached. + +Exhibit A - Source Code Form License Notice +------------------------------------------- + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at http://mozilla.org/MPL/2.0/. + +If it is not possible or desirable to put the notice in a particular +file, then You may include the notice in a location (such as a LICENSE +file in a relevant directory) where a recipient would be likely to look +for such a notice. + +You may add additional accurate notices of copyright ownership. + +Exhibit B - "Incompatible With Secondary Licenses" Notice +--------------------------------------------------------- + + This Source Code Form is "Incompatible With Secondary Licenses", as + defined by the Mozilla Public License, v. 2.0. diff --git a/infrastructure/.terraform/providers/registry.terraform.io/hashicorp/random/3.9.0/darwin_arm64/terraform-provider-random_v3.9.0_x5 b/infrastructure/.terraform/providers/registry.terraform.io/hashicorp/random/3.9.0/darwin_arm64/terraform-provider-random_v3.9.0_x5 new file mode 100755 index 0000000..0609298 Binary files /dev/null and b/infrastructure/.terraform/providers/registry.terraform.io/hashicorp/random/3.9.0/darwin_arm64/terraform-provider-random_v3.9.0_x5 differ diff --git a/infrastructure/.terraform/terraform.tfstate b/infrastructure/.terraform/terraform.tfstate new file mode 100644 index 0000000..466b8bf --- /dev/null +++ b/infrastructure/.terraform/terraform.tfstate @@ -0,0 +1,41 @@ +{ + "version": 3, + "terraform_version": "1.13.1", + "backend": { + "type": "azurerm", + "config": { + "access_key": null, + "ado_pipeline_service_connection_id": null, + "client_certificate": null, + "client_certificate_password": null, + "client_certificate_path": null, + "client_id": null, + "client_id_file_path": null, + "client_secret": null, + "client_secret_file_path": null, + "container_name": "tfstate-portfolio", + "endpoint": null, + "environment": null, + "key": "terraform.tfstate", + "lookup_blob_endpoint": null, + "metadata_host": null, + "msi_endpoint": null, + "oidc_request_token": null, + "oidc_request_url": null, + "oidc_token": null, + "oidc_token_file_path": null, + "resource_group_name": "noahspan", + "sas_token": null, + "snapshot": null, + "storage_account_name": "noahspanterraform", + "subscription_id": null, + "tenant_id": null, + "use_aks_workload_identity": null, + "use_azuread_auth": null, + "use_cli": null, + "use_msi": null, + "use_oidc": null + }, + "hash": 1379508076 + } +} \ No newline at end of file diff --git a/infrastructure/backend/test.backend.tfvar b/infrastructure/backend/test.backend.tfvar new file mode 100644 index 0000000..33d3fd5 --- /dev/null +++ b/infrastructure/backend/test.backend.tfvar @@ -0,0 +1 @@ +container_name = "tfstate-portfolio-test" \ No newline at end of file diff --git a/infrastructure/config.tf b/infrastructure/config.tf new file mode 100644 index 0000000..d696591 --- /dev/null +++ b/infrastructure/config.tf @@ -0,0 +1,4 @@ +module "environment" { + source = "./config" + environment = var.WORKSPACE +} \ No newline at end of file diff --git a/infrastructure/config/main.tf b/infrastructure/config/main.tf new file mode 100644 index 0000000..3a51349 --- /dev/null +++ b/infrastructure/config/main.tf @@ -0,0 +1,31 @@ +locals { + app_name = { + test = "portfolio-test" + prod = "portfolio-prod" + } + + container_image = { + test = "noahspan/portfolio-app:v2.0.0-alpha" + prod = "noahspan/portfolio-app:v2.0.0-alpha" + } + + container_name = { + test = "portfolio-container-test" + prod = "portfolio-container-prod" + } + + container_app_environment_name = { + test = "noahspan-test" + prod = "noahspan-prod" + } + + storage_account_name = { + test = "noahspanportfoliotest" + prod = "noahspanportfolioprod" + } + + storage_account_storage_shares = { + test = ["portfolio-test-database-share"] + prod = ["portfolio-prod-database-share"] + } +} diff --git a/infrastructure/config/outputs.tf b/infrastructure/config/outputs.tf new file mode 100644 index 0000000..0aef1e7 --- /dev/null +++ b/infrastructure/config/outputs.tf @@ -0,0 +1,23 @@ +output "app_name" { + value = local.app_name[var.environment] +} + +output "container_image" { + value = local.container_image[var.environment] +} + +output "container_name" { + value = local.container_name[var.environment] +} + +output "container_app_environment_name" { + value = local.container_app_environment_name[var.environment] +} + +output "storage_account_name" { + value = local.storage_account_name[var.environment] +} + +output "storage_account_storage_shares" { + value = local.storage_account_storage_shares[var.environment] +} \ No newline at end of file diff --git a/infrastructure/config/variables.tf b/infrastructure/config/variables.tf new file mode 100644 index 0000000..9b5503e --- /dev/null +++ b/infrastructure/config/variables.tf @@ -0,0 +1 @@ +variable "environment" {} \ No newline at end of file diff --git a/infrastructure/container_app.tf b/infrastructure/container_app.tf new file mode 100644 index 0000000..78af835 --- /dev/null +++ b/infrastructure/container_app.tf @@ -0,0 +1,195 @@ +resource "azurerm_container_app" "container_app" { + name = module.environment.app_name + container_app_environment_id = data.azurerm_container_app_environment.container_app_environment.id + resource_group_name = data.azurerm_resource_group.resource_group.name + revision_mode = "Single" + + template { + min_replicas = 0 + max_replicas = 2 + + init_container { + args = ["restore", "-if-db-not-exists", "-if-replica-exists", "/mnt/data/portfolio.db"] + cpu = 0.25 + image = "litestream/litestream:0.5.2" + memory = "0.5Gi" + name = "restore" + + volume_mounts { + name = "data" + path = "/mnt/data" + } + + volume_mounts { + name = "backup" + path = "/mnt/data/backup" + sub_path = "data" + } + + volume_mounts { + name = "backup" + path = "/etc" + sub_path = "litestream" + } + } + + container { + args = ["replicate"] + cpu = 0.25 + image = "litestream/litestream:0.5.2" + memory = "0.5Gi" + name = "replicate" + + volume_mounts { + name = "data" + path = "/mnt/data" + } + + volume_mounts { + name = "backup" + path = "/mnt/data/backup" + sub_path = "data" + } + + volume_mounts { + name = "backup" + path = "/etc" + sub_path = "litestream" + } + } + + container { + cpu = 0.25 + image = "noahspan/portfolio:20589367895" + memory = "0.5Gi" + name = "portfolio" + + env { + name = "AZURE_STORAGE_CONNECTION_STRING" + secret_name = "azure-storage-connection-string" + } + + env { + name = "AUTHORITY" + value = var.AUTHORITY + } + + env { + name = "AUDIENCE" + value = var.CLIENT_ID + } + + env { + name = "CLIENT_ID" + value = var.CLIENT_ID + } + + env { + name = "CLIENT_SECRET" + secret_name = "client-secret" + } + + env { + name = "ISSUER_URL" + value = var.ISSUER_URL + } + + env { + name = "JWKS_URI" + value = var.JWKS_URI + } + + env { + name = "NODE_ENV" + value = var.WORKSPACE + } + + env { + name = "SESSION_SECRET" + secret_name = "session-secret" + } + + env { + name = "TENANT_ID" + value = var.EXTERNAL_TENANT_ID + } + + env { + name = "DB_PATH" + value = "/mnt/data/portfolio.db" + } + + env { + name = "DB_SYNC" + value = "false" + } + + startup_probe { + failure_count_threshold = 3 + initial_delay = 15 + interval_seconds = 30 + path = "/api/health" + port = 3000 + transport = "HTTP" + } + + volume_mounts { + name = "data" + path = "/mnt/data" + } + } + + volume { + name = "backup" + storage_name = azurerm_container_app_environment_storage.container_app_environment_storage_database.name + storage_type = "AzureFile" + } + + volume { + name = "data" + storage_type = "EmptyDir" + } + } + + ingress { + allow_insecure_connections = false + external_enabled = true + target_port = 3000 + transport = "auto" + + traffic_weight { + latest_revision = true + percentage = 100 + } + } + + registry { + server = "docker.io" + username = var.DOCKER_IO_USERNAME + password_secret_name = "docker-io-password" + } + + secret { + name = "azure-storage-connection-string" + value = azurerm_storage_account.storage_account.primary_connection_string + } + + secret { + name = "client-secret" + value = var.CLIENT_SECRET + } + + secret { + name = "docker-io-password" + value = var.DOCKER_IO_PASSWORD + } + + secret { + name = "session-secret" + value = var.SESSION_SECRET + } + + lifecycle { + ignore_changes = [ template[0].container[0].image, template[0].container[0].image, template[0].init_container[0].image, registry[0].server ] + } +} \ No newline at end of file diff --git a/infrastructure/container_app_environment_storage.tf b/infrastructure/container_app_environment_storage.tf new file mode 100644 index 0000000..90aebc9 --- /dev/null +++ b/infrastructure/container_app_environment_storage.tf @@ -0,0 +1,8 @@ +resource "azurerm_container_app_environment_storage" "container_app_environment_storage_database" { + name = "${module.environment.app_name}-database" + container_app_environment_id = data.azurerm_container_app_environment.container_app_environment.id + account_name = azurerm_storage_account.storage_account.name + share_name = azurerm_storage_share.storage_share[0].name + access_key = azurerm_storage_account.storage_account.primary_access_key + access_mode = "ReadWrite" +} \ No newline at end of file diff --git a/infrastructure/main.tf b/infrastructure/main.tf new file mode 100644 index 0000000..3cc6ebe --- /dev/null +++ b/infrastructure/main.tf @@ -0,0 +1,10 @@ +data "azurerm_client_config" "current" {} + +data "azurerm_resource_group" "resource_group" { + name = var.RESOURCE_GROUP_NAME +} + +data "azurerm_container_app_environment" "container_app_environment" { + name = module.environment.container_app_environment_name + resource_group_name = var.RESOURCE_GROUP_NAME +} diff --git a/infrastructure/prod.env b/infrastructure/prod.env new file mode 100644 index 0000000..e69de29 diff --git a/infrastructure/providers.tf b/infrastructure/providers.tf new file mode 100644 index 0000000..554f8fd --- /dev/null +++ b/infrastructure/providers.tf @@ -0,0 +1,5 @@ +provider "azurerm" { + features {} +} + +provider "random" {} \ No newline at end of file diff --git a/infrastructure/storage.tf b/infrastructure/storage.tf new file mode 100644 index 0000000..01185e0 --- /dev/null +++ b/infrastructure/storage.tf @@ -0,0 +1,14 @@ +resource "azurerm_storage_account" "storage_account" { + name = module.environment.storage_account_name + resource_group_name = data.azurerm_resource_group.resource_group.name + location = data.azurerm_resource_group.resource_group.location + account_tier = "Standard" + account_replication_type = "LRS" +} + +resource "azurerm_storage_share" "storage_share" { + count = length(module.environment.storage_account_storage_shares) + name = module.environment.storage_account_storage_shares[count.index] + quota = 50 + storage_account_name = azurerm_storage_account.storage_account.name +} \ No newline at end of file diff --git a/infrastructure/terraform.tf b/infrastructure/terraform.tf new file mode 100644 index 0000000..eef9209 --- /dev/null +++ b/infrastructure/terraform.tf @@ -0,0 +1,24 @@ +terraform { + required_version = ">= 1.1.0" + + required_providers { + azurerm = { + source = "hashicorp/azurerm" + version = "4.40.0" + } + + azuread = { + source = "hashicorp/azuread" + } + + random = { + source = "hashicorp/random" + } + } + + backend "azurerm" { + resource_group_name = "noahspan" + storage_account_name = "noahspanterraform" + key = "terraform.tfstate" + } +} \ No newline at end of file diff --git a/infrastructure/test.env b/infrastructure/test.env new file mode 100644 index 0000000..e69de29 diff --git a/infrastructure/variables.tf b/infrastructure/variables.tf new file mode 100644 index 0000000..f647261 --- /dev/null +++ b/infrastructure/variables.tf @@ -0,0 +1,51 @@ +variable "AUTHORITY" { + type = string +} + +variable "CLIENT_ID" { + type = string +} + +variable "CLIENT_SECRET" { + type = string + sensitive = true +} + +variable "DOCKER_IO_PASSWORD" { + type = string + sensitive = true +} + +variable "DOCKER_IO_USERNAME" { + type = string +} + +variable "ISSUER_URL" { + type = string +} + +variable "JWKS_URI" { + type = string +} + +variable "RESOURCE_GROUP_NAME" { + type = string +} + +variable "EXTERNAL_TENANT_ID" { + type = string +} + +variable "SESSION_SECRET" { + sensitive = true + type = string +} + +variable "TENANT_ID" { + type = string +} + +variable "WORKSPACE" { + type = string +} + diff --git a/package.json b/package.json new file mode 100644 index 0000000..f6d23fb --- /dev/null +++ b/package.json @@ -0,0 +1,12 @@ +{ + "name": "noahspan-portfolio", + "version": "1.0.0", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "workspaces": [ + "api", + "cms", + "static-wfe" + ] +}