diff --git a/.github/workflows/frontend.yml b/.github/workflows/frontend.yml index 5777ce9..1f732f3 100644 --- a/.github/workflows/frontend.yml +++ b/.github/workflows/frontend.yml @@ -34,6 +34,10 @@ jobs: working-directory: ./frontend run: npm ci + - name: Generate GraphQL types + working-directory: ./frontend + run: npm run codegen + - name: Check TypeScript compilation working-directory: ./frontend run: npx tsc --noEmit diff --git a/PhantomDave.BankTracking.Api/Services/AccountService.cs b/PhantomDave.BankTracking.Api/Services/AccountService.cs index 1acb319..1b03376 100644 --- a/PhantomDave.BankTracking.Api/Services/AccountService.cs +++ b/PhantomDave.BankTracking.Api/Services/AccountService.cs @@ -22,7 +22,7 @@ public AccountService(IUnitOfWork unitOfWork) { return await _unitOfWork.Accounts.GetSingleOrDefaultAsync(a => a.Email == email); } - + public async Task> GetAllAccountsAsync() { return await _unitOfWork.Accounts.GetAllAsync(); @@ -74,11 +74,11 @@ private async Task IsEmailAlreadyPresent(string email) public async Task LoginAccountAsync(string email, string password) { var account = await GetAccountByEmail(email); - if(account == null) + if (account == null) return null; return VerifyPassword(password, account.PasswordHash) ? account : null; } - + private static string HashPassword(string password) { const int iterations = 100_000; @@ -90,7 +90,7 @@ private static string HashPassword(string password) return $"PBKDF2-SHA256${iterations}${Convert.ToBase64String(salt)}${Convert.ToBase64String(hash)}"; } - + private static bool VerifyPassword(string password, string stored) { if (string.IsNullOrWhiteSpace(stored)) diff --git a/PhantomDave.BankTracking.Api/Types/Mutations/AccountMutations.cs b/PhantomDave.BankTracking.Api/Types/Mutations/AccountMutations.cs index 838f95e..c04df18 100644 --- a/PhantomDave.BankTracking.Api/Types/Mutations/AccountMutations.cs +++ b/PhantomDave.BankTracking.Api/Types/Mutations/AccountMutations.cs @@ -53,5 +53,50 @@ public async Task CreateAccount( return AccountType.FromAccount(account); } + + /// + /// Login to an account + /// + public async Task LoginAccount( + string email, + string password, + [Service] AccountService accountService) + { + if (string.IsNullOrWhiteSpace(email)) + { + throw new GraphQLException( + ErrorBuilder.New() + .SetMessage("Email is required.") + .SetCode("BAD_USER_INPUT") + .SetExtension("field", "email") + .SetExtension("reason", "required") + .Build()); + } + + if (string.IsNullOrWhiteSpace(password)) + { + throw new GraphQLException( + ErrorBuilder.New() + .SetMessage("Password is required.") + .SetCode("BAD_USER_INPUT") + .SetExtension("field", "password") + .SetExtension("reason", "required") + .Build()); + } + + var account = await accountService.LoginAccountAsync(email, password); + if (account is null) + { + throw new GraphQLException( + ErrorBuilder.New() + .SetMessage("Invalid email or password.") + .SetCode("AUTHENTICATION_FAILED") + .SetExtension("field", "email") + .SetExtension("reason", "invalid_credentials") + .Build()); + } + + return AccountType.FromAccount(account); + } } diff --git a/PhantomDave.BankTracking.Api/Types/Queries/AccountQueries.cs b/PhantomDave.BankTracking.Api/Types/Queries/AccountQueries.cs index 009bc19..aaea9ff 100644 --- a/PhantomDave.BankTracking.Api/Types/Queries/AccountQueries.cs +++ b/PhantomDave.BankTracking.Api/Types/Queries/AccountQueries.cs @@ -29,14 +29,5 @@ public async Task> GetAccounts( var account = await accountService.GetAccountByEmail(email); return account != null ? AccountType.FromAccount(account) : null; } - - public async Task LoginAccount( - string email, - string password, - [Service] AccountService accountService) - { - var account = await accountService.LoginAccountAsync(email, password); - return account != null ? AccountType.FromAccount(account) : null; - } } diff --git a/frontend/GRAPHQL_REFACTORING.md b/frontend/GRAPHQL_REFACTORING.md new file mode 100644 index 0000000..2450e04 --- /dev/null +++ b/frontend/GRAPHQL_REFACTORING.md @@ -0,0 +1,143 @@ +# Account GraphQL Operations Refactoring + +This document explains the refactoring of account-related GraphQL operations to use Apollo Angular's typed GQL service pattern. + +## Overview + +All account operations have been migrated from using raw GraphQL queries/mutations with Apollo client to using generated, type-safe Apollo Angular GQL services. + +## What Changed + +### Before +```typescript +// Direct Apollo usage +this.apollo.query({ + query: GET_ACCOUNT_BY_EMAIL, + variables: { email } +}) + +this.apollo.mutate({ + mutation: CREATE_ACCOUNT, + variables: { email, password } +}) +``` + +### After +```typescript +// Generated typed GQL services +this.getAccountByEmailGQL.fetch({ variables: { email } }) +this.createAccountGQL.mutate({ variables: { email, password } }) +this.loginAccountGQL.mutate({ variables: { email, password } }) +``` + +## Generated GQL Classes + +The following injectable GQL classes are now available in `src/generated/graphql.ts`: + +### Queries +- **GetAccountByEmailGQL** - Fetch account by email +- **GetAccountsGQL** - Fetch all accounts + +### Mutations +- **CreateAccountGQL** - Create a new account +- **LoginAccountGQL** - Login with email and password (changed from Query to Mutation for proper authentication semantics) + +## Usage + +### Using AccountService (Recommended) +The AccountService provides high-level methods that wrap the GQL classes with error handling and state management: + +```typescript +import { AccountService } from './models/account/account-service'; + +export class MyComponent { + private readonly accountService = inject(AccountService); + + async login() { + const account = await this.accountService.loginAccount(email, password); + } + + async register() { + const result = await this.accountService.createAccount(email, password); + } +} +``` + +### Using GQL Classes Directly (Advanced) +For more control, you can inject the GQL classes directly: + +```typescript +import { LoginAccountGQL, CreateAccountGQL } from './generated/graphql'; +import { firstValueFrom } from 'rxjs'; + +export class MyComponent { + private readonly loginGQL = inject(LoginAccountGQL); + private readonly createAccountGQL = inject(CreateAccountGQL); + + async login(email: string, password: string) { + const result = await firstValueFrom( + this.loginGQL.watch({ variables: { email, password } }).valueChanges + ); + return result.data?.loginAccount; + } + + async register(email: string, password: string) { + const result = await firstValueFrom( + this.createAccountGQL.mutate({ variables: { email, password } }) + ); + return result.data?.createAccount; + } +} +``` + +## Adding New Operations + +To add a new account operation: + +1. Create a `.graphql` file in `src/graphql/`: +```graphql +# src/graphql/update-account.mutation.graphql +mutation UpdateAccount($id: Int!, $email: String!) { + updateAccount(id: $id, email: $email) { + id + email + createdAt + updatedAt + } +} +``` + +2. Run code generation: +```bash +npm run codegen +``` + +3. The new `UpdateAccountGQL` class will be generated and can be injected: +```typescript +import { UpdateAccountGQL } from './generated/graphql'; + +export class AccountService { + private readonly updateAccountGQL = inject(UpdateAccountGQL); + + async updateAccount(id: number, email: string) { + const result = await firstValueFrom( + this.updateAccountGQL.mutate({ variables: { id, email } }) + ); + return result.data?.updateAccount; + } +} +``` + +## Benefits + +1. **Type Safety**: Full TypeScript type checking for all GraphQL operations +2. **Auto-completion**: IDE provides suggestions for available fields and variables +3. **Consistency**: All operations follow the same pattern +4. **Maintainability**: Easier to add, modify, and remove operations +5. **Code Generation**: No manual type definitions needed + +## Migration Notes + +- Old `account.queries.ts` and `account.mutations.ts` files have been removed +- The `Account` interface was updated to match generated types (`updatedAt?: string | null`) +- All existing components using AccountService continue to work without changes diff --git a/frontend/codegen.ts b/frontend/codegen.ts index 9440e1c..954b334 100644 --- a/frontend/codegen.ts +++ b/frontend/codegen.ts @@ -1,7 +1,7 @@ import type { CodegenConfig } from '@graphql-codegen/cli'; const config: CodegenConfig = { - schema: 'http://localhost:5095/graphql', + schema: './schema.graphql', documents: ['src/**/*.graphql'], generates: { './src/generated/graphql.ts': { diff --git a/frontend/schema.graphql b/frontend/schema.graphql index 73c1c57..5935752 100644 --- a/frontend/schema.graphql +++ b/frontend/schema.graphql @@ -25,10 +25,10 @@ scalar DateTime type Mutation { createAccount(email: String!, password: String!): AccountType! + loginAccount(email: String!, password: String!): AccountType } type Query { accountByEmail(email: String!): AccountType accounts: [AccountType!]! - loginAccount(email: String!, password: String!): AccountType } \ No newline at end of file diff --git a/frontend/src/app/components/welcome-layout/login-component/login-component.ts b/frontend/src/app/components/welcome-layout/login-component/login-component.ts index 570df89..7d8444c 100644 --- a/frontend/src/app/components/welcome-layout/login-component/login-component.ts +++ b/frontend/src/app/components/welcome-layout/login-component/login-component.ts @@ -5,6 +5,7 @@ import {MatSelectModule} from '@angular/material/select'; import {MatInputModule} from '@angular/material/input'; import {MatFormFieldModule} from '@angular/material/form-field'; import {FlexComponent} from '../../flex-component/flex-component'; +import {AccountService} from '../../../../models/account/account-service'; @Component({ selector: 'app-login-component', @@ -22,17 +23,17 @@ import {FlexComponent} from '../../flex-component/flex-component'; }) export class LoginComponent { private readonly fb = inject(FormBuilder); + private readonly accountService = inject(AccountService); protected readonly loginForm: FormGroup = this.fb.group({ email: ['', [Validators.required, Validators.email]], password: ['', [Validators.required, Validators.minLength(6)]] }); - protected onSubmit() { + protected async onSubmit() { if (this.loginForm.valid) { const {email, password} = this.loginForm.value; - console.log('Email:', email); - console.log('Password:', password); + await this.accountService.loginAccount(email, password); } else { this.loginForm.markAllAsTouched(); } diff --git a/frontend/src/graphql/create-account.mutation.graphql b/frontend/src/graphql/create-account.mutation.graphql new file mode 100644 index 0000000..6fc1041 --- /dev/null +++ b/frontend/src/graphql/create-account.mutation.graphql @@ -0,0 +1,8 @@ +mutation CreateAccount($email: String!, $password: String!) { + createAccount(email: $email, password: $password) { + id + email + createdAt + updatedAt + } +} diff --git a/frontend/src/graphql/get-account-by-email.query.graphql b/frontend/src/graphql/get-account-by-email.query.graphql new file mode 100644 index 0000000..56f68ad --- /dev/null +++ b/frontend/src/graphql/get-account-by-email.query.graphql @@ -0,0 +1,8 @@ +query GetAccountByEmail($email: String!) { + accountByEmail(email: $email) { + id + email + createdAt + updatedAt + } +} diff --git a/frontend/src/graphql/get-accounts.query.graphql b/frontend/src/graphql/get-accounts.query.graphql new file mode 100644 index 0000000..9c11ce8 --- /dev/null +++ b/frontend/src/graphql/get-accounts.query.graphql @@ -0,0 +1,8 @@ +query GetAccounts { + accounts { + id + email + createdAt + updatedAt + } +} diff --git a/frontend/src/graphql/login-account.mutation.graphql b/frontend/src/graphql/login-account.mutation.graphql new file mode 100644 index 0000000..8e5cb75 --- /dev/null +++ b/frontend/src/graphql/login-account.mutation.graphql @@ -0,0 +1,8 @@ +mutation LoginAccount($email: String!, $password: String!) { + loginAccount(email: $email, password: $password) { + id + email + createdAt + updatedAt + } +} diff --git a/frontend/src/models/account/account-service.ts b/frontend/src/models/account/account-service.ts index 3b149b9..b5aa445 100644 --- a/frontend/src/models/account/account-service.ts +++ b/frontend/src/models/account/account-service.ts @@ -1,16 +1,22 @@ import {inject, Injectable, signal, Signal} from '@angular/core'; import {Account} from './account'; -import {Apollo} from 'apollo-angular'; -import {GET_ACCOUNT_BY_EMAIL} from './account.queries'; import {firstValueFrom} from 'rxjs'; -import {CREATE_ACCOUNT} from './account.mutations'; import { SnackbarService } from '../../shared/services/snackbar.service'; +import { + CreateAccountGQL, + GetAccountByEmailGQL, + GetAccountsGQL, + LoginAccountGQL +} from '../../generated/graphql'; @Injectable({ providedIn: 'root' }) export class AccountService { - private readonly apollo = inject(Apollo); + private readonly createAccountGQL = inject(CreateAccountGQL); + private readonly getAccountByEmailGQL = inject(GetAccountByEmailGQL); + private readonly getAccountsGQL = inject(GetAccountsGQL); + private readonly loginAccountGQL = inject(LoginAccountGQL); private readonly snackbar = inject(SnackbarService); private readonly _selectedAccount = signal(null); @@ -31,14 +37,11 @@ export class AccountService { try { const result = await firstValueFrom( - this.apollo.query<{ accountByEmail: Account | null }>({ - query: GET_ACCOUNT_BY_EMAIL, - variables: {email} - }) + this.getAccountByEmailGQL.fetch({ variables: { email } }) ); if (result?.data?.accountByEmail !== null && result?.data?.accountByEmail !== undefined) { - this._selectedAccount.set(result.data.accountByEmail); + this._selectedAccount.set(result.data.accountByEmail as Account); } if (result && (result as any).error) { @@ -60,10 +63,9 @@ export class AccountService { this._error.set(null); try { - const result = await firstValueFrom(this.apollo.mutate<{createAccount: Account | null}>({ - mutation: CREATE_ACCOUNT, - variables: {email, password} - })); + const result = await firstValueFrom( + this.createAccountGQL.mutate({ variables: { email, password } }) + ); if (result?.data?.createAccount) { this.snackbar.success('Account creato con successo'); @@ -90,4 +92,64 @@ export class AccountService { this._loading.set(false); } } + + async loginAccount(email: string, password: string): Promise { + this._loading.set(true); + this._error.set(null); + + try { + const result = await firstValueFrom( + this.loginAccountGQL.mutate({ variables: { email, password } }) + ); + + if (result?.data?.loginAccount) { + const account = result.data.loginAccount as Account; + this._selectedAccount.set(account); + this.snackbar.success('Login effettuato con successo'); + return account; + } + + if (result && (result as any).error) { + const message = (result as any).error?.message ?? 'Login fallito'; + this._error.set(message); + this.snackbar.error(message); + } + + return null; + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Errore imprevisto'; + this._error.set(message); + this.snackbar.error(message); + return null; + } finally { + this._loading.set(false); + } + } + + async getAllAccounts(): Promise { + this._loading.set(true); + this._error.set(null); + + try { + const result = await firstValueFrom( + this.getAccountsGQL.fetch({}) + ); + + if (result?.data?.accounts) { + this._accounts.set(result.data.accounts as readonly Account[]); + } + + if (result && (result as any).error) { + const message = (result as any).error?.message ?? 'Errore durante il caricamento degli account'; + this._error.set(message); + this.snackbar.error(message); + } + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Errore imprevisto'; + this._error.set(message); + this.snackbar.error(message); + } finally { + this._loading.set(false); + } + } } diff --git a/frontend/src/models/account/account.mutations.ts b/frontend/src/models/account/account.mutations.ts deleted file mode 100644 index b53f7d5..0000000 --- a/frontend/src/models/account/account.mutations.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { gql } from 'apollo-angular'; - -export const CREATE_ACCOUNT = gql` - mutation CreateAccount($email: String!, $password: String!) { - createAccount(email: $email, password: $password) { - id - email - createdAt - updatedAt - } - } -`; - diff --git a/frontend/src/models/account/account.queries.ts b/frontend/src/models/account/account.queries.ts deleted file mode 100644 index 829a774..0000000 --- a/frontend/src/models/account/account.queries.ts +++ /dev/null @@ -1,12 +0,0 @@ -import {gql} from 'apollo-angular'; - -export const GET_ACCOUNT_BY_EMAIL = gql` - query GetAccountByEmail($email: String!) { - accountByEmail(email: $email) { - id - email - createdAt - updatedAt - } - } -`; diff --git a/frontend/src/models/account/account.ts b/frontend/src/models/account/account.ts index 956a415..4575486 100644 --- a/frontend/src/models/account/account.ts +++ b/frontend/src/models/account/account.ts @@ -2,5 +2,5 @@ export interface Account { id: number; email: string; createdAt: string; - updatedAt: string | null; + updatedAt?: string | null; }