diff --git a/backend/src/_system/migrations/run-all.ts b/backend/src/_system/migrations/run-all.ts index 131c6c2..c557e87 100644 --- a/backend/src/_system/migrations/run-all.ts +++ b/backend/src/_system/migrations/run-all.ts @@ -26,6 +26,7 @@ const MIGRATION_ORDER = [ 'features/backup', // Admin backup feature; depends on update_updated_at_column() 'features/notifications', // Depends on maintenance and documents 'features/user-profile', // User profile management; independent + 'features/terms-agreement', // Terms & Conditions acceptance audit trail ]; // Base directory where migrations are copied inside the image (set by Dockerfile) diff --git a/backend/src/features/CLAUDE.md b/backend/src/features/CLAUDE.md index a08ccfc..af2a782 100644 --- a/backend/src/features/CLAUDE.md +++ b/backend/src/features/CLAUDE.md @@ -16,6 +16,7 @@ Feature capsule directory. Each feature is 100% self-contained with api/, domain | `onboarding/` | User onboarding flow | First-time user setup | | `platform/` | Vehicle data and VIN decoding | Make/model lookup, VIN validation | | `stations/` | Gas station search and favorites | Google Maps integration, station data | +| `terms-agreement/` | Terms & Conditions acceptance audit | Signup T&C, legal compliance | | `user-export/` | User data export | GDPR compliance, data portability | | `user-preferences/` | User preference management | User settings API | | `user-profile/` | User profile management | Profile CRUD, avatar handling | diff --git a/backend/src/features/auth/api/auth.controller.ts b/backend/src/features/auth/api/auth.controller.ts index 382b11b..dccb6c2 100644 --- a/backend/src/features/auth/api/auth.controller.ts +++ b/backend/src/features/auth/api/auth.controller.ts @@ -6,6 +6,8 @@ import { FastifyRequest, FastifyReply } from 'fastify'; import { AuthService } from '../domain/auth.service'; import { UserProfileRepository } from '../../user-profile/data/user-profile.repository'; +import { TermsAgreementRepository } from '../../terms-agreement/data/terms-agreement.repository'; +import { termsConfig } from '../../terms-agreement/domain/terms-config'; import { pool } from '../../../core/config/database'; import { logger } from '../../../core/logging/logger'; import { signupSchema, resendVerificationPublicSchema } from './auth.validation'; @@ -15,7 +17,22 @@ export class AuthController { constructor() { const userProfileRepository = new UserProfileRepository(pool); - this.authService = new AuthService(userProfileRepository); + const termsAgreementRepository = new TermsAgreementRepository(pool); + this.authService = new AuthService(userProfileRepository, termsAgreementRepository); + } + + /** + * Extract client IP address from request + * Checks X-Forwarded-For header for proxy scenarios (Traefik) + */ + private getClientIp(request: FastifyRequest): string { + const forwardedFor = request.headers['x-forwarded-for']; + if (forwardedFor) { + // X-Forwarded-For can be comma-separated list; first IP is the client + const ips = Array.isArray(forwardedFor) ? forwardedFor[0] : forwardedFor; + return ips.split(',')[0].trim(); + } + return request.ip || 'unknown'; } /** @@ -34,9 +51,18 @@ export class AuthController { }); } - const { email, password } = validation.data; + const { email, password, termsAccepted } = validation.data; - const result = await this.authService.signup({ email, password }); + // Extract terms data for audit trail + const termsData = { + ipAddress: this.getClientIp(request), + userAgent: (request.headers['user-agent'] as string) || 'unknown', + termsVersion: termsConfig.version, + termsUrl: termsConfig.url, + termsContentHash: termsConfig.getContentHash(), + }; + + const result = await this.authService.signup({ email, password, termsAccepted }, termsData); logger.info('User signup successful', { email, userId: result.userId }); diff --git a/backend/src/features/auth/api/auth.validation.ts b/backend/src/features/auth/api/auth.validation.ts index 814b839..223a40a 100644 --- a/backend/src/features/auth/api/auth.validation.ts +++ b/backend/src/features/auth/api/auth.validation.ts @@ -18,6 +18,9 @@ const passwordSchema = z export const signupSchema = z.object({ email: z.string().email('Invalid email format'), password: passwordSchema, + termsAccepted: z.literal(true, { + errorMap: () => ({ message: 'You must agree to the Terms & Conditions to create an account' }), + }), }); export type SignupInput = z.infer; diff --git a/backend/src/features/auth/domain/auth.service.ts b/backend/src/features/auth/domain/auth.service.ts index 53619f7..eb1a138 100644 --- a/backend/src/features/auth/domain/auth.service.ts +++ b/backend/src/features/auth/domain/auth.service.ts @@ -5,6 +5,8 @@ import { auth0ManagementClient } from '../../../core/auth/auth0-management.client'; import { UserProfileRepository } from '../../user-profile/data/user-profile.repository'; +import { TermsAgreementRepository } from '../../terms-agreement/data/terms-agreement.repository'; +import { pool } from '../../../core/config/database'; import { logger } from '../../../core/logging/logger'; import { SignupRequest, @@ -13,17 +15,21 @@ import { ResendVerificationResponse, SecurityStatusResponse, PasswordResetResponse, + TermsData, } from './auth.types'; export class AuthService { - constructor(private userProfileRepository: UserProfileRepository) {} + constructor( + private userProfileRepository: UserProfileRepository, + private termsAgreementRepository: TermsAgreementRepository + ) {} /** * Create a new user account * 1. Create user in Auth0 (which automatically sends verification email) - * 2. Create local user profile with emailVerified=false + * 2. Create local user profile and terms agreement atomically */ - async signup(request: SignupRequest): Promise { + async signup(request: SignupRequest, termsData: TermsData): Promise { const { email, password } = request; try { @@ -36,14 +42,42 @@ export class AuthService { logger.info('Auth0 user created', { auth0UserId, email }); - // Create local user profile - const userProfile = await this.userProfileRepository.create( - auth0UserId, - email, - undefined // displayName is optional - ); + // Create local user profile and terms agreement in a transaction + const client = await pool.connect(); + try { + await client.query('BEGIN'); - logger.info('User profile created', { userId: userProfile.id, email }); + // Create user profile + const profileQuery = ` + INSERT INTO user_profiles (auth0_sub, email, subscription_tier) + VALUES ($1, $2, 'free') + RETURNING id + `; + const profileResult = await client.query(profileQuery, [auth0UserId, email]); + const profileId = profileResult.rows[0].id; + + logger.info('User profile created', { userId: profileId, email }); + + // Create terms agreement + await this.termsAgreementRepository.create({ + userId: auth0UserId, + ipAddress: termsData.ipAddress, + userAgent: termsData.userAgent, + termsVersion: termsData.termsVersion, + termsUrl: termsData.termsUrl, + termsContentHash: termsData.termsContentHash, + }, client); + + logger.info('Terms agreement created', { userId: auth0UserId }); + + await client.query('COMMIT'); + } catch (error) { + await client.query('ROLLBACK'); + logger.error('Transaction failed, rolling back', { error, email }); + throw error; + } finally { + client.release(); + } return { userId: auth0UserId, diff --git a/backend/src/features/auth/domain/auth.types.ts b/backend/src/features/auth/domain/auth.types.ts index 2b6f84b..d1772c2 100644 --- a/backend/src/features/auth/domain/auth.types.ts +++ b/backend/src/features/auth/domain/auth.types.ts @@ -7,6 +7,16 @@ export interface SignupRequest { email: string; password: string; + termsAccepted: boolean; +} + +// Terms data captured during signup for audit trail +export interface TermsData { + ipAddress: string; + userAgent: string; + termsVersion: string; + termsUrl: string; + termsContentHash: string; } // Response from signup endpoint diff --git a/backend/src/features/terms-agreement/README.md b/backend/src/features/terms-agreement/README.md new file mode 100644 index 0000000..7419557 --- /dev/null +++ b/backend/src/features/terms-agreement/README.md @@ -0,0 +1,46 @@ +# Terms Agreement Feature + +Stores legal audit trail for Terms & Conditions acceptance at user signup. + +## Purpose + +Provides comprehensive legal compliance by capturing: +- **User consent**: Proof that user agreed to T&C before account creation +- **Audit fields**: IP address, user agent, timestamp, terms version, content hash + +## Data Flow + +1. User checks T&C checkbox on signup form +2. Frontend sends `termsAccepted: true` with signup request +3. Controller extracts IP (from X-Forwarded-For header), User-Agent +4. Service creates user profile and terms agreement atomically in transaction +5. Terms agreement record stores audit fields for legal compliance + +## Database Schema + +| Column | Type | Description | +|--------|------|-------------| +| id | UUID | Primary key | +| user_id | VARCHAR | Auth0 user ID | +| agreed_at | TIMESTAMPTZ | UTC timestamp of agreement | +| ip_address | VARCHAR(45) | Client IP (supports IPv6) | +| user_agent | TEXT | Browser/client user agent | +| terms_version | VARCHAR | Version string (e.g., v2026-01-03) | +| terms_url | VARCHAR | URL path to PDF | +| terms_content_hash | VARCHAR(64) | SHA-256 hash of PDF content | + +## Files + +| Path | Purpose | +|------|---------| +| `migrations/001_create_terms_agreements.sql` | Database table creation | +| `data/terms-agreement.repository.ts` | Data access layer | +| `domain/terms-agreement.types.ts` | TypeScript interfaces | +| `domain/terms-config.ts` | Static terms version and hash | +| `index.ts` | Feature exports | + +## Invariants + +- Every user in `user_profiles` has exactly one record in `terms_agreements` +- `agreed_at` is always stored in UTC +- `terms_content_hash` matches SHA-256 of PDF at signup time diff --git a/backend/src/features/terms-agreement/data/terms-agreement.repository.ts b/backend/src/features/terms-agreement/data/terms-agreement.repository.ts new file mode 100644 index 0000000..0b60f2f --- /dev/null +++ b/backend/src/features/terms-agreement/data/terms-agreement.repository.ts @@ -0,0 +1,98 @@ +/** + * @ai-summary Terms agreement data access layer + * @ai-context Provides parameterized SQL queries for terms agreement operations + */ + +import { Pool, PoolClient } from 'pg'; +import { TermsAgreement, CreateTermsAgreementRequest } from '../domain/terms-agreement.types'; +import { logger } from '../../../core/logging/logger'; + +const TERMS_AGREEMENT_COLUMNS = ` + id, user_id, agreed_at, ip_address, user_agent, + terms_version, terms_url, terms_content_hash, + created_at, updated_at +`; + +export class TermsAgreementRepository { + constructor(private pool: Pool) {} + + /** + * Create a new terms agreement record + * Can accept a PoolClient for transaction support + */ + async create( + request: CreateTermsAgreementRequest, + client?: PoolClient + ): Promise { + const query = ` + INSERT INTO terms_agreements ( + user_id, ip_address, user_agent, + terms_version, terms_url, terms_content_hash + ) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING ${TERMS_AGREEMENT_COLUMNS} + `; + + const values = [ + request.userId, + request.ipAddress, + request.userAgent, + request.termsVersion, + request.termsUrl, + request.termsContentHash, + ]; + + try { + const executor = client || this.pool; + const result = await executor.query(query, values); + + if (result.rows.length === 0) { + throw new Error('Failed to create terms agreement'); + } + + return this.mapRow(result.rows[0]); + } catch (error) { + logger.error('Error creating terms agreement', { error, userId: request.userId }); + throw error; + } + } + + /** + * Get terms agreement by user ID + */ + async getByUserId(userId: string): Promise { + const query = ` + SELECT ${TERMS_AGREEMENT_COLUMNS} + FROM terms_agreements + WHERE user_id = $1 + ORDER BY agreed_at DESC + LIMIT 1 + `; + + try { + const result = await this.pool.query(query, [userId]); + if (result.rows.length === 0) { + return null; + } + return this.mapRow(result.rows[0]); + } catch (error) { + logger.error('Error fetching terms agreement by user_id', { error, userId }); + throw error; + } + } + + private mapRow(row: any): TermsAgreement { + return { + id: row.id, + userId: row.user_id, + agreedAt: new Date(row.agreed_at), + ipAddress: row.ip_address, + userAgent: row.user_agent, + termsVersion: row.terms_version, + termsUrl: row.terms_url, + termsContentHash: row.terms_content_hash, + createdAt: new Date(row.created_at), + updatedAt: new Date(row.updated_at), + }; + } +} diff --git a/backend/src/features/terms-agreement/domain/terms-agreement.types.ts b/backend/src/features/terms-agreement/domain/terms-agreement.types.ts new file mode 100644 index 0000000..66e4c8b --- /dev/null +++ b/backend/src/features/terms-agreement/domain/terms-agreement.types.ts @@ -0,0 +1,25 @@ +/** + * @ai-summary TypeScript types for terms agreement feature + */ + +export interface TermsAgreement { + id: string; + userId: string; + agreedAt: Date; + ipAddress: string; + userAgent: string; + termsVersion: string; + termsUrl: string; + termsContentHash: string; + createdAt: Date; + updatedAt: Date; +} + +export interface CreateTermsAgreementRequest { + userId: string; + ipAddress: string; + userAgent: string; + termsVersion: string; + termsUrl: string; + termsContentHash: string; +} diff --git a/backend/src/features/terms-agreement/domain/terms-config.ts b/backend/src/features/terms-agreement/domain/terms-config.ts new file mode 100644 index 0000000..87db4a6 --- /dev/null +++ b/backend/src/features/terms-agreement/domain/terms-config.ts @@ -0,0 +1,63 @@ +/** + * @ai-summary Terms & Conditions configuration + * @ai-context Static configuration for current T&C version, computed once at startup + */ + +import { createHash } from 'crypto'; +import { readFileSync, existsSync } from 'fs'; +import { join } from 'path'; +import { logger } from '../../../core/logging/logger'; + +// Current terms version and URL (update when PDF changes) +const TERMS_VERSION = 'v2026-01-03'; +const TERMS_URL = '/docs/v2026-01-03.pdf'; + +// Compute hash once at module load (startup) +let termsContentHash: string | null = null; + +function computeTermsHash(): string { + if (termsContentHash) { + return termsContentHash; + } + + // In production, PDF is served by frontend; backend may not have access + // For development, try to read from frontend/public/docs + const possiblePaths = [ + join(__dirname, '../../../../../frontend/public/docs/v2026-01-03.pdf'), + join(process.cwd(), 'frontend/public/docs/v2026-01-03.pdf'), + ]; + + for (const pdfPath of possiblePaths) { + if (existsSync(pdfPath)) { + try { + const pdfContent = readFileSync(pdfPath); + termsContentHash = createHash('sha256').update(pdfContent).digest('hex'); + logger.info('Terms content hash computed', { + version: TERMS_VERSION, + hash: termsContentHash.substring(0, 16) + '...' + }); + return termsContentHash; + } catch (error) { + logger.warn('Failed to read terms PDF for hashing', { path: pdfPath, error }); + } + } + } + + // Fallback: use a placeholder hash (should be updated in production) + // In production, this should be pre-computed and set as an environment variable + termsContentHash = process.env['TERMS_CONTENT_HASH'] || + 'placeholder-hash-update-in-production'; + + logger.warn('Using fallback terms hash', { + hash: termsContentHash.substring(0, 16) + '...', + reason: 'PDF not found in development paths' + }); + + return termsContentHash; +} + +export const termsConfig = { + version: TERMS_VERSION, + url: TERMS_URL, + getContentHash: computeTermsHash, +}; diff --git a/backend/src/features/terms-agreement/index.ts b/backend/src/features/terms-agreement/index.ts new file mode 100644 index 0000000..bef44e0 --- /dev/null +++ b/backend/src/features/terms-agreement/index.ts @@ -0,0 +1,6 @@ +/** + * @ai-summary Terms agreement feature exports + */ + +export { TermsAgreementRepository } from './data/terms-agreement.repository'; +export { TermsAgreement, CreateTermsAgreementRequest } from './domain/terms-agreement.types'; diff --git a/backend/src/features/terms-agreement/migrations/001_create_terms_agreements.sql b/backend/src/features/terms-agreement/migrations/001_create_terms_agreements.sql new file mode 100644 index 0000000..c90ee80 --- /dev/null +++ b/backend/src/features/terms-agreement/migrations/001_create_terms_agreements.sql @@ -0,0 +1,33 @@ +-- Terms Agreements Table +-- Stores legal audit trail for Terms & Conditions acceptance at signup + +CREATE TABLE IF NOT EXISTS terms_agreements ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id VARCHAR(255) NOT NULL, + agreed_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + ip_address VARCHAR(45) NOT NULL, + user_agent TEXT NOT NULL, + terms_version VARCHAR(50) NOT NULL, + terms_url VARCHAR(255) NOT NULL, + terms_content_hash VARCHAR(64) NOT NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +-- Index for user lookup +CREATE INDEX IF NOT EXISTS idx_terms_agreements_user_id ON terms_agreements(user_id); + +-- Trigger for updated_at +CREATE OR REPLACE FUNCTION update_terms_agreements_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = CURRENT_TIMESTAMP; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS terms_agreements_updated_at ON terms_agreements; +CREATE TRIGGER terms_agreements_updated_at + BEFORE UPDATE ON terms_agreements + FOR EACH ROW + EXECUTE FUNCTION update_terms_agreements_updated_at(); diff --git a/frontend/src/features/auth/components/SignupForm.tsx b/frontend/src/features/auth/components/SignupForm.tsx index 2eb1696..ea05afe 100644 --- a/frontend/src/features/auth/components/SignupForm.tsx +++ b/frontend/src/features/auth/components/SignupForm.tsx @@ -18,6 +18,9 @@ const signupSchema = z .regex(/[A-Z]/, 'Password must contain at least one uppercase letter') .regex(/[0-9]/, 'Password must contain at least one number'), confirmPassword: z.string(), + termsAccepted: z.literal(true, { + errorMap: () => ({ message: 'You must agree to the Terms & Conditions to create an account' }), + }), }) .refine((data) => data.password === data.confirmPassword, { message: 'Passwords do not match', @@ -42,8 +45,8 @@ export const SignupForm: React.FC = ({ onSubmit, loading }) => }); const handleFormSubmit = (data: SignupRequest & { confirmPassword: string }) => { - const { email, password } = data; - onSubmit({ email, password }); + const { email, password, termsAccepted } = data; + onSubmit({ email, password, termsAccepted }); }; return ( @@ -138,6 +141,31 @@ export const SignupForm: React.FC = ({ onSubmit, loading }) => )} +
+ +
+ {errors.termsAccepted && ( +

{errors.termsAccepted.message}

+ )} +