feat: add Terms & Conditions checkbox to signup (refs #4)
All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 4m38s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 28s
Deploy to Staging / Verify Staging (pull_request) Successful in 6s
Deploy to Staging / Notify Staging Ready (pull_request) Successful in 6s
Deploy to Staging / Notify Staging Failure (pull_request) Has been skipped

- Add terms_agreements table for legal audit trail
- Create terms-agreement feature capsule with repository
- Modify signup to create terms agreement atomically
- Add checkbox with PDF link to SignupForm
- Capture IP, User-Agent, terms version, content hash
- Update CLAUDE.md documentation index

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Eric Gullickson
2026-01-03 12:27:45 -06:00
parent 0391a23bb6
commit dec91ccfc2
14 changed files with 390 additions and 15 deletions

View File

@@ -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)

View File

@@ -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 |

View File

@@ -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 });

View File

@@ -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<typeof signupSchema>;

View File

@@ -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<SignupResponse> {
async signup(request: SignupRequest, termsData: TermsData): Promise<SignupResponse> {
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,

View File

@@ -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

View File

@@ -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

View File

@@ -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<TermsAgreement> {
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<TermsAgreement | null> {
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),
};
}
}

View File

@@ -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;
}

View File

@@ -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,
};

View File

@@ -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';

View File

@@ -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();