Piattaforma ITI TECHNOLOGY: monorepo, Medusa, API, sito, area riservata e deploy
Deploy stack su Proxmox / deploy (push) Failing after 3s

Monorepo npm con apps/public (Angular SSR), apps/private (Angular SPA),
apps/api (Fastify), packages/shared e packages/styles; Medusa 2.21 in medusa/;
infrastruttura Docker (Postgres PostGIS+pgvector, Redis, Caddy, backup) e
workflow Gitea Actions per il deploy via SSH a ogni push su master.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018jQyT6dR9czzGwqb7FkG6u
This commit is contained in:
Lorenzo Sanesi
2026-10-20 09:30:48 +02:00
co-authored by Claude Opus 5.5
commit b83ce039fb
505 changed files with 84544 additions and 0 deletions
@@ -0,0 +1,268 @@
import type { ConsentDto, UserDto } from '@iti/shared'
import { ChangeDetectionStrategy, Component, computed, effect, inject, signal, viewChild } from '@angular/core'
import { rxResource } from '@angular/core/rxjs-interop'
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'
import { Title } from '@angular/platform-browser'
import { Router, RouterLink } from '@angular/router'
import { firstValueFrom } from 'rxjs'
import { ApiService, toApiError } from '../../core/api.service'
import { APP_CONFIG } from '../../core/app-config'
import { AuthStore, type SessionResponse } from '../../core/auth.store'
import { formatDateTime } from '../../core/format'
import { ROLE_LABELS } from '../../core/roles'
import { ConfirmDialog } from '../../shared/confirm-dialog'
import { applyServerErrors } from '../../shared/form-errors'
import { FormField } from '../../shared/form-field'
import { Loading, LoadError } from '../../shared/state'
import { mustMatch, PASSWORD_MIN } from '../auth/auth-helpers'
interface ConsentsResponse {
marketing: boolean
items: ConsentDto[]
}
const CONSENT_SOURCES: Record<string, string> = {
register: 'registrazione',
contact: 'modulo contatti',
showroom: 'prenotazione showroom',
configurator: 'configuratore',
checkout: 'checkout',
account: 'area riservata',
}
/** Corpo di `PATCH /me`: campi aziendali solo per le aziende, stringhe vuote → null. */
export function profilePatch(role: UserDto['role'], value: { firstName: string; lastName: string; companyName: string; vatNumber: string }) {
return {
firstName: value.firstName.trim(),
lastName: value.lastName.trim(),
...(role === 'company' ? { companyName: value.companyName.trim(), vatNumber: value.vatNumber.trim() } : {}),
}
}
/**
* Il mio account: dati anagrafici (campi aziendali per le aziende), cambio password, consensi (marketing
* revocabile, registro), richiesta di cancellazione dell'account.
*/
@Component({
selector: 'app-profile',
imports: [ReactiveFormsModule, RouterLink, FormField, ConfirmDialog, Loading, LoadError],
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './profile.html',
styles: `
.single {
grid-template-columns: 1fr;
max-width: 32rem;
}
.form-grid {
margin-bottom: var(--space-4);
}
.email {
margin-bottom: var(--space-4);
}
.consents {
width: 100%;
border-collapse: collapse;
font-size: var(--font-size-sm);
th,
td {
padding: var(--space-2);
border-bottom: 1px solid var(--color-line);
text-align: left;
}
}
.table-wrap {
overflow-x: auto;
}
details summary {
min-height: 44px;
display: flex;
align-items: center;
cursor: pointer;
}
.danger-zone {
border-color: var(--color-danger);
}
`,
})
export default class Profile {
private readonly api = inject(ApiService)
private readonly router = inject(Router)
protected readonly auth = inject(AuthStore)
protected readonly siteUrl = inject(APP_CONFIG).siteUrl
protected readonly role = computed(() => this.auth.role() ?? 'customer')
protected readonly roleLabel = computed(() => ROLE_LABELS[this.role()])
// --- Dati personali ---
protected readonly details = new FormGroup({
firstName: new FormControl('', { nonNullable: true, validators: [Validators.required, Validators.maxLength(100)] }),
lastName: new FormControl('', { nonNullable: true, validators: [Validators.required, Validators.maxLength(100)] }),
companyName: new FormControl('', { nonNullable: true, validators: [Validators.maxLength(200)] }),
vatNumber: new FormControl('', { nonNullable: true, validators: [Validators.minLength(5), Validators.maxLength(20)] }),
})
protected readonly detailsState = this.formState()
// --- Password ---
protected readonly password = new FormGroup({
currentPassword: new FormControl('', { nonNullable: true, validators: [Validators.required] }),
newPassword: new FormControl('', {
nonNullable: true,
validators: [Validators.required, Validators.minLength(PASSWORD_MIN), Validators.maxLength(128)],
}),
confirm: new FormControl('', { nonNullable: true }),
})
protected readonly passwordState = this.formState()
// --- Consensi ---
protected readonly consents = rxResource({ stream: () => this.api.get<ConsentsResponse>('/me/consents') })
protected readonly consentBusy = signal(false)
protected readonly consentError = signal<string | null>(null)
// --- Cancellazione ---
protected readonly deletion = new FormGroup({
password: new FormControl('', { nonNullable: true, validators: [Validators.required] }),
reason: new FormControl('', { nonNullable: true, validators: [Validators.maxLength(2000)] }),
})
protected readonly deletionState = this.formState()
private readonly deleteDialog = viewChild<ConfirmDialog>('deleteDialog')
protected readonly dateTime = formatDateTime
protected readonly sources = CONSENT_SOURCES
constructor() {
inject(Title).setTitle('Il mio account | Area riservata ITI TECHNOLOGY')
mustMatch(this.password.controls.newPassword, this.password.controls.confirm)
// Form precompilato con l'utente corrente (anche dopo un salvataggio)
effect(() => {
const user = this.auth.user()
if (!user) return
this.details.reset({
firstName: user.firstName,
lastName: user.lastName,
companyName: user.companyName ?? '',
vatNumber: user.vatNumber ?? '',
})
const { companyName, vatNumber } = this.details.controls
if (user.role === 'company') {
companyName.setValidators([Validators.required, Validators.maxLength(200)])
vatNumber.setValidators([Validators.required, Validators.minLength(5), Validators.maxLength(20)])
} else {
companyName.clearValidators()
vatNumber.clearValidators()
}
companyName.updateValueAndValidity()
vatNumber.updateValueAndValidity()
})
}
protected async saveDetails(): Promise<void> {
const state = this.detailsState
state.start()
if (this.details.invalid) return state.stop()
try {
const user = await firstValueFrom(this.api.patch<UserDto>('/me', profilePatch(this.role(), this.details.getRawValue())))
this.auth.setUser(user)
state.done('Dati aggiornati.')
} catch (err) {
const error = toApiError(err)
state.fail(error.code === 'VALIDATION_ERROR' && applyServerErrors(this.details, error) ? 'Controlla i campi evidenziati.' : error.message)
}
}
protected async changePassword(): Promise<void> {
const state = this.passwordState
state.start()
if (this.password.invalid) return state.stop()
try {
const { currentPassword, newPassword } = this.password.getRawValue()
const session = await firstValueFrom(
this.api.post<SessionResponse>('/auth/change-password', { currentPassword, newPassword }, { withCredentials: true }),
)
this.auth.applySession(session)
this.password.reset()
state.done('Password aggiornata. Per sicurezza abbiamo chiuso le sessioni aperte sugli altri dispositivi.')
} catch (err) {
const error = toApiError(err)
if ((error.code === 'VALIDATION_ERROR' || error.code === 'INVALID_CURRENT_PASSWORD') && applyServerErrors(this.password, error)) {
state.fail('Controlla i campi evidenziati.')
} else {
state.fail(error.code === 'RATE_LIMITED' ? 'Troppi tentativi: attendi un minuto e riprova.' : error.message)
}
}
}
protected async setMarketing(enabled: boolean): Promise<void> {
this.consentError.set(null)
this.consentBusy.set(true)
try {
const path = '/me/consents/marketing'
const res = await firstValueFrom(enabled ? this.api.post<ConsentsResponse>(path, null) : this.api.delete<ConsentsResponse>(path))
this.consents.set(res)
} catch (err) {
this.consentError.set(toApiError(err).message)
} finally {
this.consentBusy.set(false)
}
}
protected askDeletion(): void {
const state = this.deletionState
state.start()
if (this.deletion.invalid) return state.stop()
state.stop()
this.deleteDialog()?.open()
}
protected async confirmDeletion(): Promise<void> {
const state = this.deletionState
state.busy.set(true)
try {
const { password, reason } = this.deletion.getRawValue()
await firstValueFrom(this.api.post('/me/delete-account', { password, ...(reason.trim() ? { reason: reason.trim() } : {}) }))
this.deleteDialog()?.close()
// L'account è disattivato e le sessioni chiuse: si esce senza chiamare il logout
this.auth.forget()
await this.router.navigate(['/login'], { queryParams: { account: 'disattivato' }, replaceUrl: true })
} catch (err) {
this.deleteDialog()?.close()
const error = toApiError(err)
if (error.code === 'INVALID_CREDENTIALS') {
this.deletion.controls.password.setErrors({ server: 'Password non corretta' })
state.fail('Password non corretta: l’account non è stato disattivato.')
} else {
state.fail(error.message)
}
} finally {
state.busy.set(false)
}
}
/** Stato di invio di un form: tentativo fatto, in corso, messaggio di esito. */
private formState() {
const submitted = signal(false)
const busy = signal(false)
const message = signal<{ ok: boolean; text: string } | null>(null)
return {
submitted,
busy,
message,
start: () => {
submitted.set(true)
message.set(null)
busy.set(true)
},
stop: () => busy.set(false),
done: (text: string) => {
busy.set(false)
submitted.set(false)
message.set({ ok: true, text })
},
fail: (text: string) => {
busy.set(false)
message.set({ ok: false, text })
},
}
}
}