Compare commits

...

6 Commits

Author SHA1 Message Date
Eric Gullickson
754639c86d chore: update test fixtures and frontend for UUID identity (refs #217)
Some checks failed
Deploy to Staging / Build Images (pull_request) Successful in 6m41s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 52s
Deploy to Staging / Verify Staging (pull_request) Failing after 4m7s
Deploy to Staging / Notify Staging Ready (pull_request) Has been skipped
Deploy to Staging / Notify Staging Failure (pull_request) Successful in 9s
Backend test fixtures:
- Replace auth0|xxx format with UUID in all test userId values
- Update admin tests for new id/userProfileId schema
- Add missing deletionRequestedAt/deletionScheduledFor to auth test mocks
- Fix admin integration test supertest usage (app.server)

Frontend:
- AdminUser type: auth0Sub -> id + userProfileId
- admin.api.ts: all user management methods use userId (UUID) params
- useUsers/useAdmins hooks: auth0Sub -> userId/id in mutations
- AdminUsersPage + AdminUsersMobileScreen: user.auth0Sub -> user.id
- Remove encodeURIComponent (UUIDs don't need encoding)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 10:21:18 -06:00
Eric Gullickson
3b1112a9fe chore: update supporting code for UUID identity (refs #216)
- audit-log: JOIN on user_profiles.id instead of auth0_sub
- backup: use userContext.userId instead of auth0Sub
- ocr: use request.userContext.userId instead of request.user.sub
- user-profile controller: use getById() with UUID instead of getOrCreateProfile()
- user-profile service: accept UUID userId for all admin-focused methods
- user-profile repository: fix admin JOIN aliases from auth0_sub to id

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 09:59:05 -06:00
Eric Gullickson
fd9d1add24 chore: refactor admin system for UUID identity (refs #213)
Migrate admin controller, routes, validation, and users controller
from auth0Sub identifiers to UUID. Admin CRUD now uses admin UUID id,
user management routes use user_profiles UUID. Clean up debug logging.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 09:52:09 -06:00
Eric Gullickson
b418a503b2 chore: refactor user profile repository for UUID (refs #214)
Updated user-profile.repository.ts to use UUID instead of auth0_sub:
- Added getById(id) method for UUID-based lookups
- Changed all methods (except getByAuth0Sub, getOrCreate) to accept userId (UUID) instead of auth0Sub
- Updated SQL WHERE clauses from auth0_sub to id for UUID-based queries
- Fixed cross-table joins in listAllUsers and getUserWithAdminStatus to use user_profile_id
- Updated hardDeleteUser to use UUID for all DELETE statements
- Updated auth.plugin.ts to call updateEmail and updateEmailVerified with userId (UUID)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 09:39:56 -06:00
Eric Gullickson
1321440cd0 chore: update auth plugin and admin guard for UUID (refs #212)
Auth plugin now uses profile.id (UUID) as userContext.userId instead
of raw JWT sub. Admin guard queries admin_users by user_profile_id.
Auth0 Management API calls continue using auth0Sub from JWT.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 09:36:32 -06:00
Eric Gullickson
6011888e91 chore: add UUID identity migration SQL (refs #211)
Multi-phase SQL migration converting all user_id columns from
VARCHAR(255) auth0_sub to UUID referencing user_profiles.id.
Restructures admin_users with UUID PK and user_profile_id FK.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 09:33:41 -06:00
38 changed files with 1253 additions and 771 deletions

View File

@@ -31,6 +31,7 @@ const MIGRATION_ORDER = [
'features/audit-log', // Centralized audit logging; independent 'features/audit-log', // Centralized audit logging; independent
'features/ownership-costs', // Depends on vehicles and documents; TCO recurring costs 'features/ownership-costs', // Depends on vehicles and documents; TCO recurring costs
'features/subscriptions', // Stripe subscriptions; depends on user-profile, vehicles 'features/subscriptions', // Stripe subscriptions; depends on user-profile, vehicles
'core/identity-migration', // Cross-cutting UUID migration; must run after all feature tables exist
]; ];
// Base directory where migrations are copied inside the image (set by Dockerfile) // Base directory where migrations are copied inside the image (set by Dockerfile)

View File

@@ -0,0 +1,382 @@
-- Migration: 001_migrate_user_id_to_uuid.sql
-- Feature: identity-migration (cross-cutting)
-- Description: Migrate all user identity columns from VARCHAR(255) storing auth0_sub
-- to UUID referencing user_profiles.id. Admin tables restructured with UUID PKs.
-- Requires: All feature tables must exist (runs last in MIGRATION_ORDER)
BEGIN;
-- ============================================================================
-- PHASE 1: Add new UUID columns alongside existing VARCHAR columns
-- ============================================================================
-- 1a. Feature tables (17 tables with user_id VARCHAR)
ALTER TABLE vehicles ADD COLUMN IF NOT EXISTS user_profile_id UUID;
ALTER TABLE fuel_logs ADD COLUMN IF NOT EXISTS user_profile_id UUID;
ALTER TABLE maintenance_records ADD COLUMN IF NOT EXISTS user_profile_id UUID;
ALTER TABLE maintenance_schedules ADD COLUMN IF NOT EXISTS user_profile_id UUID;
ALTER TABLE documents ADD COLUMN IF NOT EXISTS user_profile_id UUID;
ALTER TABLE notification_logs ADD COLUMN IF NOT EXISTS user_profile_id UUID;
ALTER TABLE user_notifications ADD COLUMN IF NOT EXISTS user_profile_id UUID;
ALTER TABLE user_preferences ADD COLUMN IF NOT EXISTS user_profile_id UUID;
ALTER TABLE saved_stations ADD COLUMN IF NOT EXISTS user_profile_id UUID;
ALTER TABLE audit_logs ADD COLUMN IF NOT EXISTS user_profile_id UUID;
ALTER TABLE ownership_costs ADD COLUMN IF NOT EXISTS user_profile_id UUID;
ALTER TABLE email_ingestion_queue ADD COLUMN IF NOT EXISTS user_profile_id UUID;
ALTER TABLE pending_vehicle_associations ADD COLUMN IF NOT EXISTS user_profile_id UUID;
ALTER TABLE subscriptions ADD COLUMN IF NOT EXISTS user_profile_id UUID;
ALTER TABLE donations ADD COLUMN IF NOT EXISTS user_profile_id UUID;
ALTER TABLE tier_vehicle_selections ADD COLUMN IF NOT EXISTS user_profile_id UUID;
ALTER TABLE terms_agreements ADD COLUMN IF NOT EXISTS user_profile_id UUID;
-- 1b. Special user-reference columns (submitted_by/reported_by store auth0_sub)
ALTER TABLE community_stations ADD COLUMN IF NOT EXISTS submitted_by_uuid UUID;
ALTER TABLE station_removal_reports ADD COLUMN IF NOT EXISTS reported_by_uuid UUID;
-- 1c. Admin table: add id UUID and user_profile_id UUID
ALTER TABLE admin_users ADD COLUMN IF NOT EXISTS id UUID;
ALTER TABLE admin_users ADD COLUMN IF NOT EXISTS user_profile_id UUID;
-- 1d. Admin-referencing columns: add UUID equivalents
ALTER TABLE admin_audit_logs ADD COLUMN IF NOT EXISTS actor_admin_uuid UUID;
ALTER TABLE admin_audit_logs ADD COLUMN IF NOT EXISTS target_admin_uuid UUID;
ALTER TABLE admin_users ADD COLUMN IF NOT EXISTS created_by_uuid UUID;
ALTER TABLE community_stations ADD COLUMN IF NOT EXISTS reviewed_by_uuid UUID;
ALTER TABLE backup_history ADD COLUMN IF NOT EXISTS created_by_uuid UUID;
ALTER TABLE platform_change_log ADD COLUMN IF NOT EXISTS changed_by_uuid UUID;
ALTER TABLE user_profiles ADD COLUMN IF NOT EXISTS deactivated_by_uuid UUID;
-- ============================================================================
-- PHASE 2: Backfill UUID values from user_profiles join
-- ============================================================================
-- 2a. Feature tables: map user_id (auth0_sub) -> user_profiles.id (UUID)
UPDATE vehicles SET user_profile_id = up.id
FROM user_profiles up WHERE vehicles.user_id = up.auth0_sub AND vehicles.user_profile_id IS NULL;
UPDATE fuel_logs SET user_profile_id = up.id
FROM user_profiles up WHERE fuel_logs.user_id = up.auth0_sub AND fuel_logs.user_profile_id IS NULL;
UPDATE maintenance_records SET user_profile_id = up.id
FROM user_profiles up WHERE maintenance_records.user_id = up.auth0_sub AND maintenance_records.user_profile_id IS NULL;
UPDATE maintenance_schedules SET user_profile_id = up.id
FROM user_profiles up WHERE maintenance_schedules.user_id = up.auth0_sub AND maintenance_schedules.user_profile_id IS NULL;
UPDATE documents SET user_profile_id = up.id
FROM user_profiles up WHERE documents.user_id = up.auth0_sub AND documents.user_profile_id IS NULL;
UPDATE notification_logs SET user_profile_id = up.id
FROM user_profiles up WHERE notification_logs.user_id = up.auth0_sub AND notification_logs.user_profile_id IS NULL;
UPDATE user_notifications SET user_profile_id = up.id
FROM user_profiles up WHERE user_notifications.user_id = up.auth0_sub AND user_notifications.user_profile_id IS NULL;
UPDATE user_preferences SET user_profile_id = up.id
FROM user_profiles up WHERE user_preferences.user_id = up.auth0_sub AND user_preferences.user_profile_id IS NULL;
UPDATE saved_stations SET user_profile_id = up.id
FROM user_profiles up WHERE saved_stations.user_id = up.auth0_sub AND saved_stations.user_profile_id IS NULL;
UPDATE audit_logs SET user_profile_id = up.id
FROM user_profiles up WHERE audit_logs.user_id = up.auth0_sub AND audit_logs.user_profile_id IS NULL;
UPDATE ownership_costs SET user_profile_id = up.id
FROM user_profiles up WHERE ownership_costs.user_id = up.auth0_sub AND ownership_costs.user_profile_id IS NULL;
UPDATE email_ingestion_queue SET user_profile_id = up.id
FROM user_profiles up WHERE email_ingestion_queue.user_id = up.auth0_sub AND email_ingestion_queue.user_profile_id IS NULL;
UPDATE pending_vehicle_associations SET user_profile_id = up.id
FROM user_profiles up WHERE pending_vehicle_associations.user_id = up.auth0_sub AND pending_vehicle_associations.user_profile_id IS NULL;
UPDATE subscriptions SET user_profile_id = up.id
FROM user_profiles up WHERE subscriptions.user_id = up.auth0_sub AND subscriptions.user_profile_id IS NULL;
UPDATE donations SET user_profile_id = up.id
FROM user_profiles up WHERE donations.user_id = up.auth0_sub AND donations.user_profile_id IS NULL;
UPDATE tier_vehicle_selections SET user_profile_id = up.id
FROM user_profiles up WHERE tier_vehicle_selections.user_id = up.auth0_sub AND tier_vehicle_selections.user_profile_id IS NULL;
UPDATE terms_agreements SET user_profile_id = up.id
FROM user_profiles up WHERE terms_agreements.user_id = up.auth0_sub AND terms_agreements.user_profile_id IS NULL;
-- 2b. Special user columns
UPDATE community_stations SET submitted_by_uuid = up.id
FROM user_profiles up WHERE community_stations.submitted_by = up.auth0_sub AND community_stations.submitted_by_uuid IS NULL;
UPDATE station_removal_reports SET reported_by_uuid = up.id
FROM user_profiles up WHERE station_removal_reports.reported_by = up.auth0_sub AND station_removal_reports.reported_by_uuid IS NULL;
-- ============================================================================
-- PHASE 3: Admin-specific transformations
-- ============================================================================
-- 3a. Create user_profiles entries for any admin_users that lack one
INSERT INTO user_profiles (auth0_sub, email)
SELECT au.auth0_sub, au.email
FROM admin_users au
WHERE NOT EXISTS (
SELECT 1 FROM user_profiles up WHERE up.auth0_sub = au.auth0_sub
)
ON CONFLICT (auth0_sub) DO NOTHING;
-- 3b. Populate admin_users.id (DEFAULT doesn't auto-fill on ALTER ADD COLUMN for existing rows)
UPDATE admin_users SET id = uuid_generate_v4() WHERE id IS NULL;
-- 3c. Backfill admin_users.user_profile_id from user_profiles join
UPDATE admin_users SET user_profile_id = up.id
FROM user_profiles up WHERE admin_users.auth0_sub = up.auth0_sub AND admin_users.user_profile_id IS NULL;
-- 3d. Backfill admin-referencing columns: map auth0_sub -> admin_users.id UUID
UPDATE admin_audit_logs SET actor_admin_uuid = au.id
FROM admin_users au WHERE admin_audit_logs.actor_admin_id = au.auth0_sub AND admin_audit_logs.actor_admin_uuid IS NULL;
UPDATE admin_audit_logs SET target_admin_uuid = au.id
FROM admin_users au WHERE admin_audit_logs.target_admin_id = au.auth0_sub AND admin_audit_logs.target_admin_uuid IS NULL;
UPDATE admin_users au SET created_by_uuid = creator.id
FROM admin_users creator WHERE au.created_by = creator.auth0_sub AND au.created_by_uuid IS NULL;
UPDATE community_stations SET reviewed_by_uuid = au.id
FROM admin_users au WHERE community_stations.reviewed_by = au.auth0_sub AND community_stations.reviewed_by_uuid IS NULL;
UPDATE backup_history SET created_by_uuid = au.id
FROM admin_users au WHERE backup_history.created_by = au.auth0_sub AND backup_history.created_by_uuid IS NULL;
UPDATE platform_change_log SET changed_by_uuid = au.id
FROM admin_users au WHERE platform_change_log.changed_by = au.auth0_sub AND platform_change_log.changed_by_uuid IS NULL;
UPDATE user_profiles SET deactivated_by_uuid = au.id
FROM admin_users au WHERE user_profiles.deactivated_by = au.auth0_sub AND user_profiles.deactivated_by_uuid IS NULL;
-- ============================================================================
-- PHASE 4: Add constraints
-- ============================================================================
-- 4a. Set NOT NULL on feature table UUID columns (audit_logs stays nullable)
ALTER TABLE vehicles ALTER COLUMN user_profile_id SET NOT NULL;
ALTER TABLE fuel_logs ALTER COLUMN user_profile_id SET NOT NULL;
ALTER TABLE maintenance_records ALTER COLUMN user_profile_id SET NOT NULL;
ALTER TABLE maintenance_schedules ALTER COLUMN user_profile_id SET NOT NULL;
ALTER TABLE documents ALTER COLUMN user_profile_id SET NOT NULL;
ALTER TABLE notification_logs ALTER COLUMN user_profile_id SET NOT NULL;
ALTER TABLE user_notifications ALTER COLUMN user_profile_id SET NOT NULL;
ALTER TABLE user_preferences ALTER COLUMN user_profile_id SET NOT NULL;
ALTER TABLE saved_stations ALTER COLUMN user_profile_id SET NOT NULL;
-- audit_logs.user_profile_id stays NULLABLE (system actions have no user)
ALTER TABLE ownership_costs ALTER COLUMN user_profile_id SET NOT NULL;
ALTER TABLE email_ingestion_queue ALTER COLUMN user_profile_id SET NOT NULL;
ALTER TABLE pending_vehicle_associations ALTER COLUMN user_profile_id SET NOT NULL;
ALTER TABLE subscriptions ALTER COLUMN user_profile_id SET NOT NULL;
ALTER TABLE donations ALTER COLUMN user_profile_id SET NOT NULL;
ALTER TABLE tier_vehicle_selections ALTER COLUMN user_profile_id SET NOT NULL;
ALTER TABLE terms_agreements ALTER COLUMN user_profile_id SET NOT NULL;
ALTER TABLE community_stations ALTER COLUMN submitted_by_uuid SET NOT NULL;
ALTER TABLE station_removal_reports ALTER COLUMN reported_by_uuid SET NOT NULL;
-- 4b. Admin table NOT NULL constraints
ALTER TABLE admin_users ALTER COLUMN id SET NOT NULL;
ALTER TABLE admin_users ALTER COLUMN user_profile_id SET NOT NULL;
ALTER TABLE admin_audit_logs ALTER COLUMN actor_admin_uuid SET NOT NULL;
-- target_admin_uuid stays nullable (some actions have no target)
-- created_by_uuid stays nullable (bootstrap admin may not have a creator)
ALTER TABLE platform_change_log ALTER COLUMN changed_by_uuid SET NOT NULL;
-- 4c. Admin table PK transformation
ALTER TABLE admin_users DROP CONSTRAINT admin_users_pkey;
ALTER TABLE admin_users ADD PRIMARY KEY (id);
-- 4d. Add FK constraints to user_profiles(id) with ON DELETE CASCADE
ALTER TABLE vehicles ADD CONSTRAINT fk_vehicles_user_profile_id
FOREIGN KEY (user_profile_id) REFERENCES user_profiles(id) ON DELETE CASCADE;
ALTER TABLE fuel_logs ADD CONSTRAINT fk_fuel_logs_user_profile_id
FOREIGN KEY (user_profile_id) REFERENCES user_profiles(id) ON DELETE CASCADE;
ALTER TABLE maintenance_records ADD CONSTRAINT fk_maintenance_records_user_profile_id
FOREIGN KEY (user_profile_id) REFERENCES user_profiles(id) ON DELETE CASCADE;
ALTER TABLE maintenance_schedules ADD CONSTRAINT fk_maintenance_schedules_user_profile_id
FOREIGN KEY (user_profile_id) REFERENCES user_profiles(id) ON DELETE CASCADE;
ALTER TABLE documents ADD CONSTRAINT fk_documents_user_profile_id
FOREIGN KEY (user_profile_id) REFERENCES user_profiles(id) ON DELETE CASCADE;
ALTER TABLE notification_logs ADD CONSTRAINT fk_notification_logs_user_profile_id
FOREIGN KEY (user_profile_id) REFERENCES user_profiles(id) ON DELETE CASCADE;
ALTER TABLE user_notifications ADD CONSTRAINT fk_user_notifications_user_profile_id
FOREIGN KEY (user_profile_id) REFERENCES user_profiles(id) ON DELETE CASCADE;
ALTER TABLE user_preferences ADD CONSTRAINT fk_user_preferences_user_profile_id
FOREIGN KEY (user_profile_id) REFERENCES user_profiles(id) ON DELETE CASCADE;
ALTER TABLE saved_stations ADD CONSTRAINT fk_saved_stations_user_profile_id
FOREIGN KEY (user_profile_id) REFERENCES user_profiles(id) ON DELETE CASCADE;
ALTER TABLE audit_logs ADD CONSTRAINT fk_audit_logs_user_profile_id
FOREIGN KEY (user_profile_id) REFERENCES user_profiles(id) ON DELETE CASCADE;
ALTER TABLE ownership_costs ADD CONSTRAINT fk_ownership_costs_user_profile_id
FOREIGN KEY (user_profile_id) REFERENCES user_profiles(id) ON DELETE CASCADE;
ALTER TABLE email_ingestion_queue ADD CONSTRAINT fk_email_ingestion_queue_user_profile_id
FOREIGN KEY (user_profile_id) REFERENCES user_profiles(id) ON DELETE CASCADE;
ALTER TABLE pending_vehicle_associations ADD CONSTRAINT fk_pending_vehicle_assoc_user_profile_id
FOREIGN KEY (user_profile_id) REFERENCES user_profiles(id) ON DELETE CASCADE;
ALTER TABLE subscriptions ADD CONSTRAINT fk_subscriptions_user_profile_id
FOREIGN KEY (user_profile_id) REFERENCES user_profiles(id) ON DELETE CASCADE;
ALTER TABLE donations ADD CONSTRAINT fk_donations_user_profile_id
FOREIGN KEY (user_profile_id) REFERENCES user_profiles(id) ON DELETE CASCADE;
ALTER TABLE tier_vehicle_selections ADD CONSTRAINT fk_tier_vehicle_selections_user_profile_id
FOREIGN KEY (user_profile_id) REFERENCES user_profiles(id) ON DELETE CASCADE;
ALTER TABLE terms_agreements ADD CONSTRAINT fk_terms_agreements_user_profile_id
FOREIGN KEY (user_profile_id) REFERENCES user_profiles(id) ON DELETE CASCADE;
ALTER TABLE community_stations ADD CONSTRAINT fk_community_stations_submitted_by
FOREIGN KEY (submitted_by_uuid) REFERENCES user_profiles(id) ON DELETE CASCADE;
ALTER TABLE station_removal_reports ADD CONSTRAINT fk_station_removal_reports_reported_by
FOREIGN KEY (reported_by_uuid) REFERENCES user_profiles(id) ON DELETE CASCADE;
-- 4e. Admin FK constraints
ALTER TABLE admin_users ADD CONSTRAINT fk_admin_users_user_profile_id
FOREIGN KEY (user_profile_id) REFERENCES user_profiles(id);
ALTER TABLE admin_users ADD CONSTRAINT uq_admin_users_user_profile_id
UNIQUE (user_profile_id);
-- ============================================================================
-- PHASE 5: Drop old columns, rename new ones, recreate indexes
-- ============================================================================
-- 5a. Drop old FK constraints on VARCHAR user_id columns
ALTER TABLE subscriptions DROP CONSTRAINT IF EXISTS fk_subscriptions_user_id;
ALTER TABLE donations DROP CONSTRAINT IF EXISTS fk_donations_user_id;
ALTER TABLE tier_vehicle_selections DROP CONSTRAINT IF EXISTS fk_tier_vehicle_selections_user_id;
-- 5b. Drop old UNIQUE constraints involving VARCHAR columns
ALTER TABLE vehicles DROP CONSTRAINT IF EXISTS unique_user_vin;
ALTER TABLE saved_stations DROP CONSTRAINT IF EXISTS unique_user_station;
ALTER TABLE user_preferences DROP CONSTRAINT IF EXISTS user_preferences_user_id_key;
ALTER TABLE station_removal_reports DROP CONSTRAINT IF EXISTS unique_user_station_report;
-- 5c. Drop old indexes on VARCHAR columns
DROP INDEX IF EXISTS idx_vehicles_user_id;
DROP INDEX IF EXISTS idx_fuel_logs_user_id;
DROP INDEX IF EXISTS idx_maintenance_records_user_id;
DROP INDEX IF EXISTS idx_maintenance_schedules_user_id;
DROP INDEX IF EXISTS idx_documents_user_id;
DROP INDEX IF EXISTS idx_documents_user_vehicle;
DROP INDEX IF EXISTS idx_notification_logs_user_id;
DROP INDEX IF EXISTS idx_user_notifications_user_id;
DROP INDEX IF EXISTS idx_user_notifications_unread;
DROP INDEX IF EXISTS idx_user_preferences_user_id;
DROP INDEX IF EXISTS idx_saved_stations_user_id;
DROP INDEX IF EXISTS idx_audit_logs_user_created;
DROP INDEX IF EXISTS idx_ownership_costs_user_id;
DROP INDEX IF EXISTS idx_email_ingestion_queue_user_id;
DROP INDEX IF EXISTS idx_pending_vehicle_assoc_user_id;
DROP INDEX IF EXISTS idx_subscriptions_user_id;
DROP INDEX IF EXISTS idx_donations_user_id;
DROP INDEX IF EXISTS idx_tier_vehicle_selections_user_id;
DROP INDEX IF EXISTS idx_terms_agreements_user_id;
DROP INDEX IF EXISTS idx_community_stations_submitted_by;
DROP INDEX IF EXISTS idx_removal_reports_reported_by;
DROP INDEX IF EXISTS idx_admin_audit_logs_actor_id;
DROP INDEX IF EXISTS idx_admin_audit_logs_target_id;
DROP INDEX IF EXISTS idx_platform_change_log_changed_by;
-- 5d. Drop old VARCHAR user_id columns from feature tables
ALTER TABLE vehicles DROP COLUMN user_id;
ALTER TABLE fuel_logs DROP COLUMN user_id;
ALTER TABLE maintenance_records DROP COLUMN user_id;
ALTER TABLE maintenance_schedules DROP COLUMN user_id;
ALTER TABLE documents DROP COLUMN user_id;
ALTER TABLE notification_logs DROP COLUMN user_id;
ALTER TABLE user_notifications DROP COLUMN user_id;
ALTER TABLE user_preferences DROP COLUMN user_id;
ALTER TABLE saved_stations DROP COLUMN user_id;
ALTER TABLE audit_logs DROP COLUMN user_id;
ALTER TABLE ownership_costs DROP COLUMN user_id;
ALTER TABLE email_ingestion_queue DROP COLUMN user_id;
ALTER TABLE pending_vehicle_associations DROP COLUMN user_id;
ALTER TABLE subscriptions DROP COLUMN user_id;
ALTER TABLE donations DROP COLUMN user_id;
ALTER TABLE tier_vehicle_selections DROP COLUMN user_id;
ALTER TABLE terms_agreements DROP COLUMN user_id;
-- 5e. Rename user_profile_id -> user_id in feature tables
ALTER TABLE vehicles RENAME COLUMN user_profile_id TO user_id;
ALTER TABLE fuel_logs RENAME COLUMN user_profile_id TO user_id;
ALTER TABLE maintenance_records RENAME COLUMN user_profile_id TO user_id;
ALTER TABLE maintenance_schedules RENAME COLUMN user_profile_id TO user_id;
ALTER TABLE documents RENAME COLUMN user_profile_id TO user_id;
ALTER TABLE notification_logs RENAME COLUMN user_profile_id TO user_id;
ALTER TABLE user_notifications RENAME COLUMN user_profile_id TO user_id;
ALTER TABLE user_preferences RENAME COLUMN user_profile_id TO user_id;
ALTER TABLE saved_stations RENAME COLUMN user_profile_id TO user_id;
ALTER TABLE audit_logs RENAME COLUMN user_profile_id TO user_id;
ALTER TABLE ownership_costs RENAME COLUMN user_profile_id TO user_id;
ALTER TABLE email_ingestion_queue RENAME COLUMN user_profile_id TO user_id;
ALTER TABLE pending_vehicle_associations RENAME COLUMN user_profile_id TO user_id;
ALTER TABLE subscriptions RENAME COLUMN user_profile_id TO user_id;
ALTER TABLE donations RENAME COLUMN user_profile_id TO user_id;
ALTER TABLE tier_vehicle_selections RENAME COLUMN user_profile_id TO user_id;
ALTER TABLE terms_agreements RENAME COLUMN user_profile_id TO user_id;
-- 5f. Drop and rename special user columns
ALTER TABLE community_stations DROP COLUMN submitted_by;
ALTER TABLE community_stations RENAME COLUMN submitted_by_uuid TO submitted_by;
ALTER TABLE station_removal_reports DROP COLUMN reported_by;
ALTER TABLE station_removal_reports RENAME COLUMN reported_by_uuid TO reported_by;
-- 5g. Drop and rename admin-referencing columns
ALTER TABLE admin_users DROP COLUMN auth0_sub;
ALTER TABLE admin_users DROP COLUMN created_by;
ALTER TABLE admin_users RENAME COLUMN created_by_uuid TO created_by;
ALTER TABLE admin_audit_logs DROP COLUMN actor_admin_id;
ALTER TABLE admin_audit_logs DROP COLUMN target_admin_id;
ALTER TABLE admin_audit_logs RENAME COLUMN actor_admin_uuid TO actor_admin_id;
ALTER TABLE admin_audit_logs RENAME COLUMN target_admin_uuid TO target_admin_id;
ALTER TABLE community_stations DROP COLUMN reviewed_by;
ALTER TABLE community_stations RENAME COLUMN reviewed_by_uuid TO reviewed_by;
ALTER TABLE backup_history DROP COLUMN created_by;
ALTER TABLE backup_history RENAME COLUMN created_by_uuid TO created_by;
ALTER TABLE platform_change_log DROP COLUMN changed_by;
ALTER TABLE platform_change_log RENAME COLUMN changed_by_uuid TO changed_by;
ALTER TABLE user_profiles DROP COLUMN deactivated_by;
ALTER TABLE user_profiles RENAME COLUMN deactivated_by_uuid TO deactivated_by;
-- 5h. Recreate indexes on new UUID columns (feature tables)
CREATE INDEX idx_vehicles_user_id ON vehicles(user_id);
CREATE INDEX idx_fuel_logs_user_id ON fuel_logs(user_id);
CREATE INDEX idx_maintenance_records_user_id ON maintenance_records(user_id);
CREATE INDEX idx_maintenance_schedules_user_id ON maintenance_schedules(user_id);
CREATE INDEX idx_documents_user_id ON documents(user_id);
CREATE INDEX idx_documents_user_vehicle ON documents(user_id, vehicle_id);
CREATE INDEX idx_notification_logs_user_id ON notification_logs(user_id);
CREATE INDEX idx_user_notifications_user_id ON user_notifications(user_id);
CREATE INDEX idx_user_notifications_unread ON user_notifications(user_id, created_at DESC) WHERE is_read = false;
CREATE INDEX idx_user_preferences_user_id ON user_preferences(user_id);
CREATE INDEX idx_saved_stations_user_id ON saved_stations(user_id);
CREATE INDEX idx_audit_logs_user_created ON audit_logs(user_id, created_at DESC);
CREATE INDEX idx_ownership_costs_user_id ON ownership_costs(user_id);
CREATE INDEX idx_email_ingestion_queue_user_id ON email_ingestion_queue(user_id);
CREATE INDEX idx_pending_vehicle_assoc_user_id ON pending_vehicle_associations(user_id);
CREATE INDEX idx_subscriptions_user_id ON subscriptions(user_id);
CREATE INDEX idx_donations_user_id ON donations(user_id);
CREATE INDEX idx_tier_vehicle_selections_user_id ON tier_vehicle_selections(user_id);
CREATE INDEX idx_terms_agreements_user_id ON terms_agreements(user_id);
-- 5i. Recreate indexes on special columns
CREATE INDEX idx_community_stations_submitted_by ON community_stations(submitted_by);
CREATE INDEX idx_removal_reports_reported_by ON station_removal_reports(reported_by);
CREATE INDEX idx_admin_audit_logs_actor_id ON admin_audit_logs(actor_admin_id);
CREATE INDEX idx_admin_audit_logs_target_id ON admin_audit_logs(target_admin_id);
CREATE INDEX idx_platform_change_log_changed_by ON platform_change_log(changed_by);
-- 5j. Recreate UNIQUE constraints on new UUID columns
ALTER TABLE vehicles ADD CONSTRAINT unique_user_vin UNIQUE(user_id, vin);
ALTER TABLE saved_stations ADD CONSTRAINT unique_user_station UNIQUE(user_id, place_id);
ALTER TABLE user_preferences ADD CONSTRAINT user_preferences_user_id_key UNIQUE(user_id);
ALTER TABLE station_removal_reports ADD CONSTRAINT unique_user_station_report UNIQUE(station_id, reported_by);
COMMIT;

View File

@@ -17,7 +17,7 @@ const createRequest = (subscriptionTier?: string): Partial<FastifyRequest> => {
} }
return { return {
userContext: { userContext: {
userId: 'auth0|user123456789', userId: '550e8400-e29b-41d4-a716-446655440000',
email: 'user@example.com', email: 'user@example.com',
emailVerified: true, emailVerified: true,
onboardingCompleted: true, onboardingCompleted: true,

View File

@@ -58,9 +58,9 @@ const adminGuardPlugin: FastifyPluginAsync = async (fastify) => {
// Check if user is in admin_users table and not revoked // Check if user is in admin_users table and not revoked
const query = ` const query = `
SELECT auth0_sub, email, role, revoked_at SELECT id, user_profile_id, email, role, revoked_at
FROM admin_users FROM admin_users
WHERE auth0_sub = $1 AND revoked_at IS NULL WHERE user_profile_id = $1 AND revoked_at IS NULL
LIMIT 1 LIMIT 1
`; `;

View File

@@ -121,11 +121,14 @@ const authPlugin: FastifyPluginAsync = async (fastify) => {
try { try {
await request.jwtVerify(); await request.jwtVerify();
const userId = request.user?.sub; // Two identifiers: auth0Sub (external, for Auth0 API) and userId (internal UUID, for all DB operations)
if (!userId) { const auth0Sub = request.user?.sub;
if (!auth0Sub) {
throw new Error('Missing user ID in JWT'); throw new Error('Missing user ID in JWT');
} }
let userId: string = auth0Sub; // Default to auth0Sub; overwritten with UUID after profile load
// Get or create user profile from database // Get or create user profile from database
let email = request.user?.email; let email = request.user?.email;
let displayName: string | undefined; let displayName: string | undefined;
@@ -137,28 +140,29 @@ const authPlugin: FastifyPluginAsync = async (fastify) => {
// If JWT doesn't have email, fetch from Auth0 Management API // If JWT doesn't have email, fetch from Auth0 Management API
if (!email || email.includes('@unknown.local')) { if (!email || email.includes('@unknown.local')) {
try { try {
const auth0User = await auth0ManagementClient.getUser(userId); const auth0User = await auth0ManagementClient.getUser(auth0Sub);
if (auth0User.email) { if (auth0User.email) {
email = auth0User.email; email = auth0User.email;
emailVerified = auth0User.emailVerified; emailVerified = auth0User.emailVerified;
logger.info('Fetched email from Auth0 Management API', { logger.info('Fetched email from Auth0 Management API', {
userId: userId.substring(0, 8) + '...', userId: auth0Sub.substring(0, 8) + '...',
hasEmail: true, hasEmail: true,
}); });
} }
} catch (auth0Error) { } catch (auth0Error) {
logger.warn('Failed to fetch user from Auth0 Management API', { logger.warn('Failed to fetch user from Auth0 Management API', {
userId: userId.substring(0, 8) + '...', userId: auth0Sub.substring(0, 8) + '...',
error: auth0Error instanceof Error ? auth0Error.message : 'Unknown error', error: auth0Error instanceof Error ? auth0Error.message : 'Unknown error',
}); });
} }
} }
// Get or create profile with correct email // Get or create profile with correct email
const profile = await profileRepo.getOrCreate(userId, { const profile = await profileRepo.getOrCreate(auth0Sub, {
email: email || `${userId}@unknown.local`, email: email || `${auth0Sub}@unknown.local`,
displayName: request.user?.name || request.user?.nickname, displayName: request.user?.name || request.user?.nickname,
}); });
userId = profile.id;
// If profile has placeholder email but we now have real email, update it // If profile has placeholder email but we now have real email, update it
if (profile.email.includes('@unknown.local') && email && !email.includes('@unknown.local')) { if (profile.email.includes('@unknown.local') && email && !email.includes('@unknown.local')) {
@@ -178,7 +182,7 @@ const authPlugin: FastifyPluginAsync = async (fastify) => {
// Sync email verification status from Auth0 if needed // Sync email verification status from Auth0 if needed
if (!emailVerified) { if (!emailVerified) {
try { try {
const isVerifiedInAuth0 = await auth0ManagementClient.checkEmailVerified(userId); const isVerifiedInAuth0 = await auth0ManagementClient.checkEmailVerified(auth0Sub);
if (isVerifiedInAuth0 && !profile.emailVerified) { if (isVerifiedInAuth0 && !profile.emailVerified) {
await profileRepo.updateEmailVerified(userId, true); await profileRepo.updateEmailVerified(userId, true);
emailVerified = true; emailVerified = true;
@@ -197,7 +201,7 @@ const authPlugin: FastifyPluginAsync = async (fastify) => {
} catch (profileError) { } catch (profileError) {
// Log but don't fail auth if profile fetch fails // Log but don't fail auth if profile fetch fails
logger.warn('Failed to fetch user profile', { logger.warn('Failed to fetch user profile', {
userId: userId.substring(0, 8) + '...', userId: auth0Sub.substring(0, 8) + '...',
error: profileError instanceof Error ? profileError.message : 'Unknown error', error: profileError instanceof Error ? profileError.message : 'Unknown error',
}); });
// Fall back to JWT email if available // Fall back to JWT email if available

View File

@@ -26,7 +26,7 @@ describe('tier guard plugin', () => {
// Mock authenticate to set userContext // Mock authenticate to set userContext
authenticateMock = jest.fn(async (request: FastifyRequest) => { authenticateMock = jest.fn(async (request: FastifyRequest) => {
request.userContext = { request.userContext = {
userId: 'auth0|user123', userId: '550e8400-e29b-41d4-a716-446655440000',
email: 'user@example.com', email: 'user@example.com',
emailVerified: true, emailVerified: true,
onboardingCompleted: true, onboardingCompleted: true,
@@ -48,7 +48,7 @@ describe('tier guard plugin', () => {
it('allows access when user tier meets minimum', async () => { it('allows access when user tier meets minimum', async () => {
authenticateMock.mockImplementation(async (request: FastifyRequest) => { authenticateMock.mockImplementation(async (request: FastifyRequest) => {
request.userContext = { request.userContext = {
userId: 'auth0|user123', userId: '550e8400-e29b-41d4-a716-446655440000',
email: 'user@example.com', email: 'user@example.com',
emailVerified: true, emailVerified: true,
onboardingCompleted: true, onboardingCompleted: true,
@@ -71,7 +71,7 @@ describe('tier guard plugin', () => {
it('allows access when user tier exceeds minimum', async () => { it('allows access when user tier exceeds minimum', async () => {
authenticateMock.mockImplementation(async (request: FastifyRequest) => { authenticateMock.mockImplementation(async (request: FastifyRequest) => {
request.userContext = { request.userContext = {
userId: 'auth0|user123', userId: '550e8400-e29b-41d4-a716-446655440000',
email: 'user@example.com', email: 'user@example.com',
emailVerified: true, emailVerified: true,
onboardingCompleted: true, onboardingCompleted: true,
@@ -130,7 +130,7 @@ describe('tier guard plugin', () => {
it('allows pro tier access to pro feature', async () => { it('allows pro tier access to pro feature', async () => {
authenticateMock.mockImplementation(async (request: FastifyRequest) => { authenticateMock.mockImplementation(async (request: FastifyRequest) => {
request.userContext = { request.userContext = {
userId: 'auth0|user123', userId: '550e8400-e29b-41d4-a716-446655440000',
email: 'user@example.com', email: 'user@example.com',
emailVerified: true, emailVerified: true,
onboardingCompleted: true, onboardingCompleted: true,

View File

@@ -6,11 +6,12 @@
import { FastifyRequest, FastifyReply } from 'fastify'; import { FastifyRequest, FastifyReply } from 'fastify';
import { AdminService } from '../domain/admin.service'; import { AdminService } from '../domain/admin.service';
import { AdminRepository } from '../data/admin.repository'; import { AdminRepository } from '../data/admin.repository';
import { UserProfileRepository } from '../../user-profile/data/user-profile.repository';
import { pool } from '../../../core/config/database'; import { pool } from '../../../core/config/database';
import { logger } from '../../../core/logging/logger'; import { logger } from '../../../core/logging/logger';
import { import {
CreateAdminInput, CreateAdminInput,
AdminAuth0SubInput, AdminIdInput,
AuditLogsQueryInput, AuditLogsQueryInput,
BulkCreateAdminInput, BulkCreateAdminInput,
BulkRevokeAdminInput, BulkRevokeAdminInput,
@@ -18,7 +19,7 @@ import {
} from './admin.validation'; } from './admin.validation';
import { import {
createAdminSchema, createAdminSchema,
adminAuth0SubSchema, adminIdSchema,
auditLogsQuerySchema, auditLogsQuerySchema,
bulkCreateAdminSchema, bulkCreateAdminSchema,
bulkRevokeAdminSchema, bulkRevokeAdminSchema,
@@ -33,10 +34,12 @@ import {
export class AdminController { export class AdminController {
private adminService: AdminService; private adminService: AdminService;
private userProfileRepository: UserProfileRepository;
constructor() { constructor() {
const repository = new AdminRepository(pool); const repository = new AdminRepository(pool);
this.adminService = new AdminService(repository); this.adminService = new AdminService(repository);
this.userProfileRepository = new UserProfileRepository(pool);
} }
/** /**
@@ -47,49 +50,18 @@ export class AdminController {
const userId = request.userContext?.userId; const userId = request.userContext?.userId;
const userEmail = this.resolveUserEmail(request); const userEmail = this.resolveUserEmail(request);
console.log('[DEBUG] Admin verify - userId:', userId);
console.log('[DEBUG] Admin verify - userEmail:', userEmail);
if (userEmail && request.userContext) { if (userEmail && request.userContext) {
request.userContext.email = userEmail.toLowerCase(); request.userContext.email = userEmail.toLowerCase();
} }
if (!userId && !userEmail) { if (!userId) {
console.log('[DEBUG] Admin verify - No userId or userEmail, returning 401');
return reply.code(401).send({ return reply.code(401).send({
error: 'Unauthorized', error: 'Unauthorized',
message: 'User context missing' message: 'User context missing'
}); });
} }
let adminRecord = userId const adminRecord = await this.adminService.getAdminByUserProfileId(userId);
? await this.adminService.getAdminByAuth0Sub(userId)
: null;
console.log('[DEBUG] Admin verify - adminRecord by auth0Sub:', adminRecord ? 'FOUND' : 'NOT FOUND');
// Fallback: attempt to resolve admin by email for legacy records
if (!adminRecord && userEmail) {
const emailMatch = await this.adminService.getAdminByEmail(userEmail.toLowerCase());
console.log('[DEBUG] Admin verify - emailMatch:', emailMatch ? 'FOUND' : 'NOT FOUND');
if (emailMatch) {
console.log('[DEBUG] Admin verify - emailMatch.auth0Sub:', emailMatch.auth0Sub);
console.log('[DEBUG] Admin verify - emailMatch.revokedAt:', emailMatch.revokedAt);
}
if (emailMatch && !emailMatch.revokedAt) {
// If the stored auth0Sub differs, link it to the authenticated user
if (userId && emailMatch.auth0Sub !== userId) {
console.log('[DEBUG] Admin verify - Calling linkAdminAuth0Sub to update auth0Sub');
adminRecord = await this.adminService.linkAdminAuth0Sub(userEmail, userId);
console.log('[DEBUG] Admin verify - adminRecord after link:', adminRecord ? 'SUCCESS' : 'FAILED');
} else {
console.log('[DEBUG] Admin verify - Using emailMatch as adminRecord');
adminRecord = emailMatch;
}
}
}
if (adminRecord && !adminRecord.revokedAt) { if (adminRecord && !adminRecord.revokedAt) {
if (request.userContext) { if (request.userContext) {
@@ -97,12 +69,11 @@ export class AdminController {
request.userContext.adminRecord = adminRecord; request.userContext.adminRecord = adminRecord;
} }
console.log('[DEBUG] Admin verify - Returning isAdmin: true');
// User is an active admin
return reply.code(200).send({ return reply.code(200).send({
isAdmin: true, isAdmin: true,
adminRecord: { adminRecord: {
auth0Sub: adminRecord.auth0Sub, id: adminRecord.id,
userProfileId: adminRecord.userProfileId,
email: adminRecord.email, email: adminRecord.email,
role: adminRecord.role role: adminRecord.role
} }
@@ -114,14 +85,11 @@ export class AdminController {
request.userContext.adminRecord = undefined; request.userContext.adminRecord = undefined;
} }
console.log('[DEBUG] Admin verify - Returning isAdmin: false');
// User is not an admin
return reply.code(200).send({ return reply.code(200).send({
isAdmin: false, isAdmin: false,
adminRecord: null adminRecord: null
}); });
} catch (error) { } catch (error) {
console.log('[DEBUG] Admin verify - Error caught:', error instanceof Error ? error.message : 'Unknown error');
logger.error('Error verifying admin access', { logger.error('Error verifying admin access', {
error: error instanceof Error ? error.message : 'Unknown error', error: error instanceof Error ? error.message : 'Unknown error',
userId: request.userContext?.userId?.substring(0, 8) + '...' userId: request.userContext?.userId?.substring(0, 8) + '...'
@@ -139,9 +107,9 @@ export class AdminController {
*/ */
async listAdmins(request: FastifyRequest, reply: FastifyReply) { async listAdmins(request: FastifyRequest, reply: FastifyReply) {
try { try {
const actorId = request.userContext?.userId; const actorUserProfileId = request.userContext?.userId;
if (!actorId) { if (!actorUserProfileId) {
return reply.code(401).send({ return reply.code(401).send({
error: 'Unauthorized', error: 'Unauthorized',
message: 'User context missing' message: 'User context missing'
@@ -150,11 +118,6 @@ export class AdminController {
const admins = await this.adminService.getAllAdmins(); const admins = await this.adminService.getAllAdmins();
// Log VIEW action
await this.adminService.getAdminByAuth0Sub(actorId);
// Note: Not logging VIEW as it would create excessive audit entries
// VIEW logging can be enabled if needed for compliance
return reply.code(200).send({ return reply.code(200).send({
total: admins.length, total: admins.length,
admins admins
@@ -162,7 +125,7 @@ export class AdminController {
} catch (error: any) { } catch (error: any) {
logger.error('Error listing admins', { logger.error('Error listing admins', {
error: error.message, error: error.message,
actorId: request.userContext?.userId actorUserProfileId: request.userContext?.userId
}); });
return reply.code(500).send({ return reply.code(500).send({
error: 'Internal server error', error: 'Internal server error',
@@ -179,15 +142,24 @@ export class AdminController {
reply: FastifyReply reply: FastifyReply
) { ) {
try { try {
const actorId = request.userContext?.userId; const actorUserProfileId = request.userContext?.userId;
if (!actorId) { if (!actorUserProfileId) {
return reply.code(401).send({ return reply.code(401).send({
error: 'Unauthorized', error: 'Unauthorized',
message: 'User context missing' message: 'User context missing'
}); });
} }
// Get actor's admin record to get admin ID
const actorAdmin = await this.adminService.getAdminByUserProfileId(actorUserProfileId);
if (!actorAdmin) {
return reply.code(403).send({
error: 'Forbidden',
message: 'Actor is not an admin'
});
}
// Validate request body // Validate request body
const validation = createAdminSchema.safeParse(request.body); const validation = createAdminSchema.safeParse(request.body);
if (!validation.success) { if (!validation.success) {
@@ -200,23 +172,27 @@ export class AdminController {
const { email, role } = validation.data; const { email, role } = validation.data;
// Generate auth0Sub for the new admin // Look up user profile by email to get UUID
// In production, this should be the actual Auth0 user ID const userProfile = await this.userProfileRepository.getByEmail(email);
// For now, we'll use email-based identifier if (!userProfile) {
const auth0Sub = `auth0|${email.replace('@', '_at_')}`; return reply.code(404).send({
error: 'Not Found',
message: `No user profile found with email ${email}. User must sign up first.`
});
}
const admin = await this.adminService.createAdmin( const admin = await this.adminService.createAdmin(
email, email,
role, role,
auth0Sub, userProfile.id,
actorId actorAdmin.id
); );
return reply.code(201).send(admin); return reply.code(201).send(admin);
} catch (error: any) { } catch (error: any) {
logger.error('Error creating admin', { logger.error('Error creating admin', {
error: error.message, error: error.message,
actorId: request.userContext?.userId actorUserProfileId: request.userContext?.userId
}); });
if (error.message.includes('already exists')) { if (error.message.includes('already exists')) {
@@ -234,36 +210,45 @@ export class AdminController {
} }
/** /**
* PATCH /api/admin/admins/:auth0Sub/revoke - Revoke admin access * PATCH /api/admin/admins/:id/revoke - Revoke admin access
*/ */
async revokeAdmin( async revokeAdmin(
request: FastifyRequest<{ Params: AdminAuth0SubInput }>, request: FastifyRequest<{ Params: AdminIdInput }>,
reply: FastifyReply reply: FastifyReply
) { ) {
try { try {
const actorId = request.userContext?.userId; const actorUserProfileId = request.userContext?.userId;
if (!actorId) { if (!actorUserProfileId) {
return reply.code(401).send({ return reply.code(401).send({
error: 'Unauthorized', error: 'Unauthorized',
message: 'User context missing' message: 'User context missing'
}); });
} }
// Get actor's admin record
const actorAdmin = await this.adminService.getAdminByUserProfileId(actorUserProfileId);
if (!actorAdmin) {
return reply.code(403).send({
error: 'Forbidden',
message: 'Actor is not an admin'
});
}
// Validate params // Validate params
const validation = adminAuth0SubSchema.safeParse(request.params); const validation = adminIdSchema.safeParse(request.params);
if (!validation.success) { if (!validation.success) {
return reply.code(400).send({ return reply.code(400).send({
error: 'Bad Request', error: 'Bad Request',
message: 'Invalid auth0Sub parameter', message: 'Invalid admin ID parameter',
details: validation.error.errors details: validation.error.errors
}); });
} }
const { auth0Sub } = validation.data; const { id } = validation.data;
// Check if admin exists // Check if admin exists
const targetAdmin = await this.adminService.getAdminByAuth0Sub(auth0Sub); const targetAdmin = await this.adminService.getAdminById(id);
if (!targetAdmin) { if (!targetAdmin) {
return reply.code(404).send({ return reply.code(404).send({
error: 'Not Found', error: 'Not Found',
@@ -272,14 +257,14 @@ export class AdminController {
} }
// Revoke the admin (service handles last admin check) // Revoke the admin (service handles last admin check)
const admin = await this.adminService.revokeAdmin(auth0Sub, actorId); const admin = await this.adminService.revokeAdmin(id, actorAdmin.id);
return reply.code(200).send(admin); return reply.code(200).send(admin);
} catch (error: any) { } catch (error: any) {
logger.error('Error revoking admin', { logger.error('Error revoking admin', {
error: error.message, error: error.message,
actorId: request.userContext?.userId, actorUserProfileId: request.userContext?.userId,
targetAuth0Sub: request.params.auth0Sub targetAdminId: (request.params as any).id
}); });
if (error.message.includes('Cannot revoke the last active admin')) { if (error.message.includes('Cannot revoke the last active admin')) {
@@ -304,36 +289,45 @@ export class AdminController {
} }
/** /**
* PATCH /api/admin/admins/:auth0Sub/reinstate - Restore revoked admin * PATCH /api/admin/admins/:id/reinstate - Restore revoked admin
*/ */
async reinstateAdmin( async reinstateAdmin(
request: FastifyRequest<{ Params: AdminAuth0SubInput }>, request: FastifyRequest<{ Params: AdminIdInput }>,
reply: FastifyReply reply: FastifyReply
) { ) {
try { try {
const actorId = request.userContext?.userId; const actorUserProfileId = request.userContext?.userId;
if (!actorId) { if (!actorUserProfileId) {
return reply.code(401).send({ return reply.code(401).send({
error: 'Unauthorized', error: 'Unauthorized',
message: 'User context missing' message: 'User context missing'
}); });
} }
// Get actor's admin record
const actorAdmin = await this.adminService.getAdminByUserProfileId(actorUserProfileId);
if (!actorAdmin) {
return reply.code(403).send({
error: 'Forbidden',
message: 'Actor is not an admin'
});
}
// Validate params // Validate params
const validation = adminAuth0SubSchema.safeParse(request.params); const validation = adminIdSchema.safeParse(request.params);
if (!validation.success) { if (!validation.success) {
return reply.code(400).send({ return reply.code(400).send({
error: 'Bad Request', error: 'Bad Request',
message: 'Invalid auth0Sub parameter', message: 'Invalid admin ID parameter',
details: validation.error.errors details: validation.error.errors
}); });
} }
const { auth0Sub } = validation.data; const { id } = validation.data;
// Check if admin exists // Check if admin exists
const targetAdmin = await this.adminService.getAdminByAuth0Sub(auth0Sub); const targetAdmin = await this.adminService.getAdminById(id);
if (!targetAdmin) { if (!targetAdmin) {
return reply.code(404).send({ return reply.code(404).send({
error: 'Not Found', error: 'Not Found',
@@ -342,14 +336,14 @@ export class AdminController {
} }
// Reinstate the admin // Reinstate the admin
const admin = await this.adminService.reinstateAdmin(auth0Sub, actorId); const admin = await this.adminService.reinstateAdmin(id, actorAdmin.id);
return reply.code(200).send(admin); return reply.code(200).send(admin);
} catch (error: any) { } catch (error: any) {
logger.error('Error reinstating admin', { logger.error('Error reinstating admin', {
error: error.message, error: error.message,
actorId: request.userContext?.userId, actorUserProfileId: request.userContext?.userId,
targetAuth0Sub: request.params.auth0Sub targetAdminId: (request.params as any).id
}); });
if (error.message.includes('not found')) { if (error.message.includes('not found')) {
@@ -418,15 +412,24 @@ export class AdminController {
reply: FastifyReply reply: FastifyReply
) { ) {
try { try {
const actorId = request.userContext?.userId; const actorUserProfileId = request.userContext?.userId;
if (!actorId) { if (!actorUserProfileId) {
return reply.code(401).send({ return reply.code(401).send({
error: 'Unauthorized', error: 'Unauthorized',
message: 'User context missing' message: 'User context missing'
}); });
} }
// Get actor's admin record
const actorAdmin = await this.adminService.getAdminByUserProfileId(actorUserProfileId);
if (!actorAdmin) {
return reply.code(403).send({
error: 'Forbidden',
message: 'Actor is not an admin'
});
}
// Validate request body // Validate request body
const validation = bulkCreateAdminSchema.safeParse(request.body); const validation = bulkCreateAdminSchema.safeParse(request.body);
if (!validation.success) { if (!validation.success) {
@@ -447,15 +450,21 @@ export class AdminController {
try { try {
const { email, role = 'admin' } = adminInput; const { email, role = 'admin' } = adminInput;
// Generate auth0Sub for the new admin // Look up user profile by email to get UUID
// In production, this should be the actual Auth0 user ID const userProfile = await this.userProfileRepository.getByEmail(email);
const auth0Sub = `auth0|${email.replace('@', '_at_')}`; if (!userProfile) {
failed.push({
email,
error: `No user profile found with email ${email}. User must sign up first.`
});
continue;
}
const admin = await this.adminService.createAdmin( const admin = await this.adminService.createAdmin(
email, email,
role, role,
auth0Sub, userProfile.id,
actorId actorAdmin.id
); );
created.push(admin); created.push(admin);
@@ -463,7 +472,7 @@ export class AdminController {
logger.error('Error creating admin in bulk operation', { logger.error('Error creating admin in bulk operation', {
error: error.message, error: error.message,
email: adminInput.email, email: adminInput.email,
actorId actorAdminId: actorAdmin.id
}); });
failed.push({ failed.push({
@@ -485,7 +494,7 @@ export class AdminController {
} catch (error: any) { } catch (error: any) {
logger.error('Error in bulk create admins', { logger.error('Error in bulk create admins', {
error: error.message, error: error.message,
actorId: request.userContext?.userId actorUserProfileId: request.userContext?.userId
}); });
return reply.code(500).send({ return reply.code(500).send({
@@ -503,15 +512,24 @@ export class AdminController {
reply: FastifyReply reply: FastifyReply
) { ) {
try { try {
const actorId = request.userContext?.userId; const actorUserProfileId = request.userContext?.userId;
if (!actorId) { if (!actorUserProfileId) {
return reply.code(401).send({ return reply.code(401).send({
error: 'Unauthorized', error: 'Unauthorized',
message: 'User context missing' message: 'User context missing'
}); });
} }
// Get actor's admin record
const actorAdmin = await this.adminService.getAdminByUserProfileId(actorUserProfileId);
if (!actorAdmin) {
return reply.code(403).send({
error: 'Forbidden',
message: 'Actor is not an admin'
});
}
// Validate request body // Validate request body
const validation = bulkRevokeAdminSchema.safeParse(request.body); const validation = bulkRevokeAdminSchema.safeParse(request.body);
if (!validation.success) { if (!validation.success) {
@@ -522,37 +540,36 @@ export class AdminController {
}); });
} }
const { auth0Subs } = validation.data; const { ids } = validation.data;
const revoked: AdminUser[] = []; const revoked: AdminUser[] = [];
const failed: Array<{ auth0Sub: string; error: string }> = []; const failed: Array<{ id: string; error: string }> = [];
// Process each revocation sequentially to maintain data consistency // Process each revocation sequentially to maintain data consistency
for (const auth0Sub of auth0Subs) { for (const id of ids) {
try { try {
// Check if admin exists // Check if admin exists
const targetAdmin = await this.adminService.getAdminByAuth0Sub(auth0Sub); const targetAdmin = await this.adminService.getAdminById(id);
if (!targetAdmin) { if (!targetAdmin) {
failed.push({ failed.push({
auth0Sub, id,
error: 'Admin user not found' error: 'Admin user not found'
}); });
continue; continue;
} }
// Attempt to revoke the admin // Attempt to revoke the admin
const admin = await this.adminService.revokeAdmin(auth0Sub, actorId); const admin = await this.adminService.revokeAdmin(id, actorAdmin.id);
revoked.push(admin); revoked.push(admin);
} catch (error: any) { } catch (error: any) {
logger.error('Error revoking admin in bulk operation', { logger.error('Error revoking admin in bulk operation', {
error: error.message, error: error.message,
auth0Sub, adminId: id,
actorId actorAdminId: actorAdmin.id
}); });
// Special handling for "last admin" constraint
failed.push({ failed.push({
auth0Sub, id,
error: error.message || 'Failed to revoke admin' error: error.message || 'Failed to revoke admin'
}); });
} }
@@ -570,7 +587,7 @@ export class AdminController {
} catch (error: any) { } catch (error: any) {
logger.error('Error in bulk revoke admins', { logger.error('Error in bulk revoke admins', {
error: error.message, error: error.message,
actorId: request.userContext?.userId actorUserProfileId: request.userContext?.userId
}); });
return reply.code(500).send({ return reply.code(500).send({
@@ -588,15 +605,24 @@ export class AdminController {
reply: FastifyReply reply: FastifyReply
) { ) {
try { try {
const actorId = request.userContext?.userId; const actorUserProfileId = request.userContext?.userId;
if (!actorId) { if (!actorUserProfileId) {
return reply.code(401).send({ return reply.code(401).send({
error: 'Unauthorized', error: 'Unauthorized',
message: 'User context missing' message: 'User context missing'
}); });
} }
// Get actor's admin record
const actorAdmin = await this.adminService.getAdminByUserProfileId(actorUserProfileId);
if (!actorAdmin) {
return reply.code(403).send({
error: 'Forbidden',
message: 'Actor is not an admin'
});
}
// Validate request body // Validate request body
const validation = bulkReinstateAdminSchema.safeParse(request.body); const validation = bulkReinstateAdminSchema.safeParse(request.body);
if (!validation.success) { if (!validation.success) {
@@ -607,36 +633,36 @@ export class AdminController {
}); });
} }
const { auth0Subs } = validation.data; const { ids } = validation.data;
const reinstated: AdminUser[] = []; const reinstated: AdminUser[] = [];
const failed: Array<{ auth0Sub: string; error: string }> = []; const failed: Array<{ id: string; error: string }> = [];
// Process each reinstatement sequentially to maintain data consistency // Process each reinstatement sequentially to maintain data consistency
for (const auth0Sub of auth0Subs) { for (const id of ids) {
try { try {
// Check if admin exists // Check if admin exists
const targetAdmin = await this.adminService.getAdminByAuth0Sub(auth0Sub); const targetAdmin = await this.adminService.getAdminById(id);
if (!targetAdmin) { if (!targetAdmin) {
failed.push({ failed.push({
auth0Sub, id,
error: 'Admin user not found' error: 'Admin user not found'
}); });
continue; continue;
} }
// Attempt to reinstate the admin // Attempt to reinstate the admin
const admin = await this.adminService.reinstateAdmin(auth0Sub, actorId); const admin = await this.adminService.reinstateAdmin(id, actorAdmin.id);
reinstated.push(admin); reinstated.push(admin);
} catch (error: any) { } catch (error: any) {
logger.error('Error reinstating admin in bulk operation', { logger.error('Error reinstating admin in bulk operation', {
error: error.message, error: error.message,
auth0Sub, adminId: id,
actorId actorAdminId: actorAdmin.id
}); });
failed.push({ failed.push({
auth0Sub, id,
error: error.message || 'Failed to reinstate admin' error: error.message || 'Failed to reinstate admin'
}); });
} }
@@ -654,7 +680,7 @@ export class AdminController {
} catch (error: any) { } catch (error: any) {
logger.error('Error in bulk reinstate admins', { logger.error('Error in bulk reinstate admins', {
error: error.message, error: error.message,
actorId: request.userContext?.userId actorUserProfileId: request.userContext?.userId
}); });
return reply.code(500).send({ return reply.code(500).send({
@@ -665,9 +691,6 @@ export class AdminController {
} }
private resolveUserEmail(request: FastifyRequest): string | undefined { private resolveUserEmail(request: FastifyRequest): string | undefined {
console.log('[DEBUG] resolveUserEmail - request.userContext:', JSON.stringify(request.userContext, null, 2));
console.log('[DEBUG] resolveUserEmail - request.user:', JSON.stringify((request as any).user, null, 2));
const candidates: Array<string | undefined> = [ const candidates: Array<string | undefined> = [
request.userContext?.email, request.userContext?.email,
(request as any).user?.email, (request as any).user?.email,
@@ -676,15 +699,11 @@ export class AdminController {
(request as any).user?.preferred_username, (request as any).user?.preferred_username,
]; ];
console.log('[DEBUG] resolveUserEmail - candidates:', candidates);
for (const value of candidates) { for (const value of candidates) {
if (typeof value === 'string' && value.includes('@')) { if (typeof value === 'string' && value.includes('@')) {
console.log('[DEBUG] resolveUserEmail - found email:', value);
return value.trim(); return value.trim();
} }
} }
console.log('[DEBUG] resolveUserEmail - no email found');
return undefined; return undefined;
} }
} }

View File

@@ -8,7 +8,7 @@ import { AdminController } from './admin.controller';
import { UsersController } from './users.controller'; import { UsersController } from './users.controller';
import { import {
CreateAdminInput, CreateAdminInput,
AdminAuth0SubInput, AdminIdInput,
BulkCreateAdminInput, BulkCreateAdminInput,
BulkRevokeAdminInput, BulkRevokeAdminInput,
BulkReinstateAdminInput, BulkReinstateAdminInput,
@@ -17,7 +17,7 @@ import {
} from './admin.validation'; } from './admin.validation';
import { import {
ListUsersQueryInput, ListUsersQueryInput,
UserAuth0SubInput, UserIdInput,
UpdateTierInput, UpdateTierInput,
DeactivateUserInput, DeactivateUserInput,
UpdateProfileInput, UpdateProfileInput,
@@ -65,14 +65,14 @@ export const adminRoutes: FastifyPluginAsync = async (fastify) => {
handler: adminController.createAdmin.bind(adminController) handler: adminController.createAdmin.bind(adminController)
}); });
// PATCH /api/admin/admins/:auth0Sub/revoke - Revoke admin access // PATCH /api/admin/admins/:id/revoke - Revoke admin access
fastify.patch<{ Params: AdminAuth0SubInput }>('/admin/admins/:auth0Sub/revoke', { fastify.patch<{ Params: AdminIdInput }>('/admin/admins/:id/revoke', {
preHandler: [fastify.requireAdmin], preHandler: [fastify.requireAdmin],
handler: adminController.revokeAdmin.bind(adminController) handler: adminController.revokeAdmin.bind(adminController)
}); });
// PATCH /api/admin/admins/:auth0Sub/reinstate - Restore revoked admin // PATCH /api/admin/admins/:id/reinstate - Restore revoked admin
fastify.patch<{ Params: AdminAuth0SubInput }>('/admin/admins/:auth0Sub/reinstate', { fastify.patch<{ Params: AdminIdInput }>('/admin/admins/:id/reinstate', {
preHandler: [fastify.requireAdmin], preHandler: [fastify.requireAdmin],
handler: adminController.reinstateAdmin.bind(adminController) handler: adminController.reinstateAdmin.bind(adminController)
}); });
@@ -117,50 +117,50 @@ export const adminRoutes: FastifyPluginAsync = async (fastify) => {
handler: usersController.listUsers.bind(usersController) handler: usersController.listUsers.bind(usersController)
}); });
// GET /api/admin/users/:auth0Sub - Get single user details // GET /api/admin/users/:userId - Get single user details
fastify.get<{ Params: UserAuth0SubInput }>('/admin/users/:auth0Sub', { fastify.get<{ Params: UserIdInput }>('/admin/users/:userId', {
preHandler: [fastify.requireAdmin], preHandler: [fastify.requireAdmin],
handler: usersController.getUser.bind(usersController) handler: usersController.getUser.bind(usersController)
}); });
// GET /api/admin/users/:auth0Sub/vehicles - Get user's vehicles (admin view) // GET /api/admin/users/:userId/vehicles - Get user's vehicles (admin view)
fastify.get<{ Params: UserAuth0SubInput }>('/admin/users/:auth0Sub/vehicles', { fastify.get<{ Params: UserIdInput }>('/admin/users/:userId/vehicles', {
preHandler: [fastify.requireAdmin], preHandler: [fastify.requireAdmin],
handler: usersController.getUserVehicles.bind(usersController) handler: usersController.getUserVehicles.bind(usersController)
}); });
// PATCH /api/admin/users/:auth0Sub/tier - Update subscription tier // PATCH /api/admin/users/:userId/tier - Update subscription tier
fastify.patch<{ Params: UserAuth0SubInput; Body: UpdateTierInput }>('/admin/users/:auth0Sub/tier', { fastify.patch<{ Params: UserIdInput; Body: UpdateTierInput }>('/admin/users/:userId/tier', {
preHandler: [fastify.requireAdmin], preHandler: [fastify.requireAdmin],
handler: usersController.updateTier.bind(usersController) handler: usersController.updateTier.bind(usersController)
}); });
// PATCH /api/admin/users/:auth0Sub/deactivate - Soft delete user // PATCH /api/admin/users/:userId/deactivate - Soft delete user
fastify.patch<{ Params: UserAuth0SubInput; Body: DeactivateUserInput }>('/admin/users/:auth0Sub/deactivate', { fastify.patch<{ Params: UserIdInput; Body: DeactivateUserInput }>('/admin/users/:userId/deactivate', {
preHandler: [fastify.requireAdmin], preHandler: [fastify.requireAdmin],
handler: usersController.deactivateUser.bind(usersController) handler: usersController.deactivateUser.bind(usersController)
}); });
// PATCH /api/admin/users/:auth0Sub/reactivate - Restore deactivated user // PATCH /api/admin/users/:userId/reactivate - Restore deactivated user
fastify.patch<{ Params: UserAuth0SubInput }>('/admin/users/:auth0Sub/reactivate', { fastify.patch<{ Params: UserIdInput }>('/admin/users/:userId/reactivate', {
preHandler: [fastify.requireAdmin], preHandler: [fastify.requireAdmin],
handler: usersController.reactivateUser.bind(usersController) handler: usersController.reactivateUser.bind(usersController)
}); });
// PATCH /api/admin/users/:auth0Sub/profile - Update user email/displayName // PATCH /api/admin/users/:userId/profile - Update user email/displayName
fastify.patch<{ Params: UserAuth0SubInput; Body: UpdateProfileInput }>('/admin/users/:auth0Sub/profile', { fastify.patch<{ Params: UserIdInput; Body: UpdateProfileInput }>('/admin/users/:userId/profile', {
preHandler: [fastify.requireAdmin], preHandler: [fastify.requireAdmin],
handler: usersController.updateProfile.bind(usersController) handler: usersController.updateProfile.bind(usersController)
}); });
// PATCH /api/admin/users/:auth0Sub/promote - Promote user to admin // PATCH /api/admin/users/:userId/promote - Promote user to admin
fastify.patch<{ Params: UserAuth0SubInput; Body: PromoteToAdminInput }>('/admin/users/:auth0Sub/promote', { fastify.patch<{ Params: UserIdInput; Body: PromoteToAdminInput }>('/admin/users/:userId/promote', {
preHandler: [fastify.requireAdmin], preHandler: [fastify.requireAdmin],
handler: usersController.promoteToAdmin.bind(usersController) handler: usersController.promoteToAdmin.bind(usersController)
}); });
// DELETE /api/admin/users/:auth0Sub - Hard delete user (permanent) // DELETE /api/admin/users/:userId - Hard delete user (permanent)
fastify.delete<{ Params: UserAuth0SubInput }>('/admin/users/:auth0Sub', { fastify.delete<{ Params: UserIdInput }>('/admin/users/:userId', {
preHandler: [fastify.requireAdmin], preHandler: [fastify.requireAdmin],
handler: usersController.hardDeleteUser.bind(usersController) handler: usersController.hardDeleteUser.bind(usersController)
}); });

View File

@@ -10,8 +10,8 @@ export const createAdminSchema = z.object({
role: z.enum(['admin', 'super_admin']).default('admin'), role: z.enum(['admin', 'super_admin']).default('admin'),
}); });
export const adminAuth0SubSchema = z.object({ export const adminIdSchema = z.object({
auth0Sub: z.string().min(1, 'auth0Sub is required'), id: z.string().uuid('Invalid admin ID format'),
}); });
export const auditLogsQuerySchema = z.object({ export const auditLogsQuerySchema = z.object({
@@ -29,14 +29,14 @@ export const bulkCreateAdminSchema = z.object({
}); });
export const bulkRevokeAdminSchema = z.object({ export const bulkRevokeAdminSchema = z.object({
auth0Subs: z.array(z.string().min(1, 'auth0Sub cannot be empty')) ids: z.array(z.string().uuid('Invalid admin ID format'))
.min(1, 'At least one auth0Sub must be provided') .min(1, 'At least one admin ID must be provided')
.max(100, 'Maximum 100 admins per batch'), .max(100, 'Maximum 100 admins per batch'),
}); });
export const bulkReinstateAdminSchema = z.object({ export const bulkReinstateAdminSchema = z.object({
auth0Subs: z.array(z.string().min(1, 'auth0Sub cannot be empty')) ids: z.array(z.string().uuid('Invalid admin ID format'))
.min(1, 'At least one auth0Sub must be provided') .min(1, 'At least one admin ID must be provided')
.max(100, 'Maximum 100 admins per batch'), .max(100, 'Maximum 100 admins per batch'),
}); });
@@ -49,7 +49,7 @@ export const bulkDeleteCatalogSchema = z.object({
}); });
export type CreateAdminInput = z.infer<typeof createAdminSchema>; export type CreateAdminInput = z.infer<typeof createAdminSchema>;
export type AdminAuth0SubInput = z.infer<typeof adminAuth0SubSchema>; export type AdminIdInput = z.infer<typeof adminIdSchema>;
export type AuditLogsQueryInput = z.infer<typeof auditLogsQuerySchema>; export type AuditLogsQueryInput = z.infer<typeof auditLogsQuerySchema>;
export type BulkCreateAdminInput = z.infer<typeof bulkCreateAdminSchema>; export type BulkCreateAdminInput = z.infer<typeof bulkCreateAdminSchema>;
export type BulkRevokeAdminInput = z.infer<typeof bulkRevokeAdminSchema>; export type BulkRevokeAdminInput = z.infer<typeof bulkRevokeAdminSchema>;

View File

@@ -14,13 +14,13 @@ import { pool } from '../../../core/config/database';
import { logger } from '../../../core/logging/logger'; import { logger } from '../../../core/logging/logger';
import { import {
listUsersQuerySchema, listUsersQuerySchema,
userAuth0SubSchema, userIdSchema,
updateTierSchema, updateTierSchema,
deactivateUserSchema, deactivateUserSchema,
updateProfileSchema, updateProfileSchema,
promoteToAdminSchema, promoteToAdminSchema,
ListUsersQueryInput, ListUsersQueryInput,
UserAuth0SubInput, UserIdInput,
UpdateTierInput, UpdateTierInput,
DeactivateUserInput, DeactivateUserInput,
UpdateProfileInput, UpdateProfileInput,
@@ -95,10 +95,10 @@ export class UsersController {
} }
/** /**
* GET /api/admin/users/:auth0Sub/vehicles - Get user's vehicles (admin view) * GET /api/admin/users/:userId/vehicles - Get user's vehicles (admin view)
*/ */
async getUserVehicles( async getUserVehicles(
request: FastifyRequest<{ Params: UserAuth0SubInput }>, request: FastifyRequest<{ Params: UserIdInput }>,
reply: FastifyReply reply: FastifyReply
) { ) {
try { try {
@@ -119,7 +119,7 @@ export class UsersController {
} }
// Validate path param // Validate path param
const parseResult = userAuth0SubSchema.safeParse(request.params); const parseResult = userIdSchema.safeParse(request.params);
if (!parseResult.success) { if (!parseResult.success) {
return reply.code(400).send({ return reply.code(400).send({
error: 'Validation error', error: 'Validation error',
@@ -127,14 +127,14 @@ export class UsersController {
}); });
} }
const { auth0Sub } = parseResult.data; const { userId } = parseResult.data;
const vehicles = await this.userProfileRepository.getUserVehiclesForAdmin(auth0Sub); const vehicles = await this.userProfileRepository.getUserVehiclesForAdmin(userId);
return reply.code(200).send({ vehicles }); return reply.code(200).send({ vehicles });
} catch (error) { } catch (error) {
logger.error('Error getting user vehicles', { logger.error('Error getting user vehicles', {
error: error instanceof Error ? error.message : 'Unknown error', error: error instanceof Error ? error.message : 'Unknown error',
auth0Sub: request.params?.auth0Sub, userId: (request.params as any)?.userId,
}); });
return reply.code(500).send({ return reply.code(500).send({
@@ -186,10 +186,10 @@ export class UsersController {
} }
/** /**
* GET /api/admin/users/:auth0Sub - Get single user details * GET /api/admin/users/:userId - Get single user details
*/ */
async getUser( async getUser(
request: FastifyRequest<{ Params: UserAuth0SubInput }>, request: FastifyRequest<{ Params: UserIdInput }>,
reply: FastifyReply reply: FastifyReply
) { ) {
try { try {
@@ -202,7 +202,7 @@ export class UsersController {
} }
// Validate path param // Validate path param
const parseResult = userAuth0SubSchema.safeParse(request.params); const parseResult = userIdSchema.safeParse(request.params);
if (!parseResult.success) { if (!parseResult.success) {
return reply.code(400).send({ return reply.code(400).send({
error: 'Validation error', error: 'Validation error',
@@ -210,8 +210,8 @@ export class UsersController {
}); });
} }
const { auth0Sub } = parseResult.data; const { userId } = parseResult.data;
const user = await this.userProfileService.getUserDetails(auth0Sub); const user = await this.userProfileService.getUserDetails(userId);
if (!user) { if (!user) {
return reply.code(404).send({ return reply.code(404).send({
@@ -224,7 +224,7 @@ export class UsersController {
} catch (error) { } catch (error) {
logger.error('Error getting user details', { logger.error('Error getting user details', {
error: error instanceof Error ? error.message : 'Unknown error', error: error instanceof Error ? error.message : 'Unknown error',
auth0Sub: request.params?.auth0Sub, userId: (request.params as any)?.userId,
}); });
return reply.code(500).send({ return reply.code(500).send({
@@ -235,12 +235,12 @@ export class UsersController {
} }
/** /**
* PATCH /api/admin/users/:auth0Sub/tier - Update subscription tier * PATCH /api/admin/users/:userId/tier - Update subscription tier
* Uses subscriptionsService.adminOverrideTier() to sync both subscriptions.tier * Uses subscriptionsService.adminOverrideTier() to sync both subscriptions.tier
* and user_profiles.subscription_tier atomically * and user_profiles.subscription_tier atomically
*/ */
async updateTier( async updateTier(
request: FastifyRequest<{ Params: UserAuth0SubInput; Body: UpdateTierInput }>, request: FastifyRequest<{ Params: UserIdInput; Body: UpdateTierInput }>,
reply: FastifyReply reply: FastifyReply
) { ) {
try { try {
@@ -253,7 +253,7 @@ export class UsersController {
} }
// Validate path param // Validate path param
const paramsResult = userAuth0SubSchema.safeParse(request.params); const paramsResult = userIdSchema.safeParse(request.params);
if (!paramsResult.success) { if (!paramsResult.success) {
return reply.code(400).send({ return reply.code(400).send({
error: 'Validation error', error: 'Validation error',
@@ -270,11 +270,11 @@ export class UsersController {
}); });
} }
const { auth0Sub } = paramsResult.data; const { userId } = paramsResult.data;
const { subscriptionTier } = bodyResult.data; const { subscriptionTier } = bodyResult.data;
// Verify user exists before attempting tier change // Verify user exists before attempting tier change
const currentUser = await this.userProfileService.getUserDetails(auth0Sub); const currentUser = await this.userProfileService.getUserDetails(userId);
if (!currentUser) { if (!currentUser) {
return reply.code(404).send({ return reply.code(404).send({
error: 'Not found', error: 'Not found',
@@ -285,34 +285,34 @@ export class UsersController {
const previousTier = currentUser.subscriptionTier; const previousTier = currentUser.subscriptionTier;
// Use subscriptionsService to update both tables atomically // Use subscriptionsService to update both tables atomically
await this.subscriptionsService.adminOverrideTier(auth0Sub, subscriptionTier); await this.subscriptionsService.adminOverrideTier(userId, subscriptionTier);
// Log audit action // Log audit action
await this.adminRepository.logAuditAction( await this.adminRepository.logAuditAction(
actorId, actorId,
'UPDATE_TIER', 'UPDATE_TIER',
auth0Sub, userId,
'user_profile', 'user_profile',
currentUser.id, currentUser.id,
{ previousTier, newTier: subscriptionTier } { previousTier, newTier: subscriptionTier }
); );
logger.info('User subscription tier updated via admin', { logger.info('User subscription tier updated via admin', {
auth0Sub, userId,
previousTier, previousTier,
newTier: subscriptionTier, newTier: subscriptionTier,
actorAuth0Sub: actorId, actorId,
}); });
// Return updated user profile // Return updated user profile
const updatedUser = await this.userProfileService.getUserDetails(auth0Sub); const updatedUser = await this.userProfileService.getUserDetails(userId);
return reply.code(200).send(updatedUser); return reply.code(200).send(updatedUser);
} catch (error) { } catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error'; const errorMessage = error instanceof Error ? error.message : 'Unknown error';
logger.error('Error updating user tier', { logger.error('Error updating user tier', {
error: errorMessage, error: errorMessage,
auth0Sub: request.params?.auth0Sub, userId: (request.params as any)?.userId,
}); });
if (errorMessage === 'User not found') { if (errorMessage === 'User not found') {
@@ -330,10 +330,10 @@ export class UsersController {
} }
/** /**
* PATCH /api/admin/users/:auth0Sub/deactivate - Soft delete user * PATCH /api/admin/users/:userId/deactivate - Soft delete user
*/ */
async deactivateUser( async deactivateUser(
request: FastifyRequest<{ Params: UserAuth0SubInput; Body: DeactivateUserInput }>, request: FastifyRequest<{ Params: UserIdInput; Body: DeactivateUserInput }>,
reply: FastifyReply reply: FastifyReply
) { ) {
try { try {
@@ -346,7 +346,7 @@ export class UsersController {
} }
// Validate path param // Validate path param
const paramsResult = userAuth0SubSchema.safeParse(request.params); const paramsResult = userIdSchema.safeParse(request.params);
if (!paramsResult.success) { if (!paramsResult.success) {
return reply.code(400).send({ return reply.code(400).send({
error: 'Validation error', error: 'Validation error',
@@ -363,11 +363,11 @@ export class UsersController {
}); });
} }
const { auth0Sub } = paramsResult.data; const { userId } = paramsResult.data;
const { reason } = bodyResult.data; const { reason } = bodyResult.data;
const deactivatedUser = await this.userProfileService.deactivateUser( const deactivatedUser = await this.userProfileService.deactivateUser(
auth0Sub, userId,
actorId, actorId,
reason reason
); );
@@ -378,7 +378,7 @@ export class UsersController {
logger.error('Error deactivating user', { logger.error('Error deactivating user', {
error: errorMessage, error: errorMessage,
auth0Sub: request.params?.auth0Sub, userId: (request.params as any)?.userId,
}); });
if (errorMessage === 'User not found') { if (errorMessage === 'User not found') {
@@ -410,10 +410,10 @@ export class UsersController {
} }
/** /**
* PATCH /api/admin/users/:auth0Sub/reactivate - Restore deactivated user * PATCH /api/admin/users/:userId/reactivate - Restore deactivated user
*/ */
async reactivateUser( async reactivateUser(
request: FastifyRequest<{ Params: UserAuth0SubInput }>, request: FastifyRequest<{ Params: UserIdInput }>,
reply: FastifyReply reply: FastifyReply
) { ) {
try { try {
@@ -426,7 +426,7 @@ export class UsersController {
} }
// Validate path param // Validate path param
const paramsResult = userAuth0SubSchema.safeParse(request.params); const paramsResult = userIdSchema.safeParse(request.params);
if (!paramsResult.success) { if (!paramsResult.success) {
return reply.code(400).send({ return reply.code(400).send({
error: 'Validation error', error: 'Validation error',
@@ -434,10 +434,10 @@ export class UsersController {
}); });
} }
const { auth0Sub } = paramsResult.data; const { userId } = paramsResult.data;
const reactivatedUser = await this.userProfileService.reactivateUser( const reactivatedUser = await this.userProfileService.reactivateUser(
auth0Sub, userId,
actorId actorId
); );
@@ -447,7 +447,7 @@ export class UsersController {
logger.error('Error reactivating user', { logger.error('Error reactivating user', {
error: errorMessage, error: errorMessage,
auth0Sub: request.params?.auth0Sub, userId: (request.params as any)?.userId,
}); });
if (errorMessage === 'User not found') { if (errorMessage === 'User not found') {
@@ -472,10 +472,10 @@ export class UsersController {
} }
/** /**
* PATCH /api/admin/users/:auth0Sub/profile - Update user email/displayName * PATCH /api/admin/users/:userId/profile - Update user email/displayName
*/ */
async updateProfile( async updateProfile(
request: FastifyRequest<{ Params: UserAuth0SubInput; Body: UpdateProfileInput }>, request: FastifyRequest<{ Params: UserIdInput; Body: UpdateProfileInput }>,
reply: FastifyReply reply: FastifyReply
) { ) {
try { try {
@@ -488,7 +488,7 @@ export class UsersController {
} }
// Validate path param // Validate path param
const paramsResult = userAuth0SubSchema.safeParse(request.params); const paramsResult = userIdSchema.safeParse(request.params);
if (!paramsResult.success) { if (!paramsResult.success) {
return reply.code(400).send({ return reply.code(400).send({
error: 'Validation error', error: 'Validation error',
@@ -505,11 +505,11 @@ export class UsersController {
}); });
} }
const { auth0Sub } = paramsResult.data; const { userId } = paramsResult.data;
const updates = bodyResult.data; const updates = bodyResult.data;
const updatedUser = await this.userProfileService.adminUpdateProfile( const updatedUser = await this.userProfileService.adminUpdateProfile(
auth0Sub, userId,
updates, updates,
actorId actorId
); );
@@ -520,7 +520,7 @@ export class UsersController {
logger.error('Error updating user profile', { logger.error('Error updating user profile', {
error: errorMessage, error: errorMessage,
auth0Sub: request.params?.auth0Sub, userId: (request.params as any)?.userId,
}); });
if (errorMessage === 'User not found') { if (errorMessage === 'User not found') {
@@ -538,10 +538,10 @@ export class UsersController {
} }
/** /**
* PATCH /api/admin/users/:auth0Sub/promote - Promote user to admin * PATCH /api/admin/users/:userId/promote - Promote user to admin
*/ */
async promoteToAdmin( async promoteToAdmin(
request: FastifyRequest<{ Params: UserAuth0SubInput; Body: PromoteToAdminInput }>, request: FastifyRequest<{ Params: UserIdInput; Body: PromoteToAdminInput }>,
reply: FastifyReply reply: FastifyReply
) { ) {
try { try {
@@ -554,7 +554,7 @@ export class UsersController {
} }
// Validate path param // Validate path param
const paramsResult = userAuth0SubSchema.safeParse(request.params); const paramsResult = userIdSchema.safeParse(request.params);
if (!paramsResult.success) { if (!paramsResult.success) {
return reply.code(400).send({ return reply.code(400).send({
error: 'Validation error', error: 'Validation error',
@@ -571,11 +571,11 @@ export class UsersController {
}); });
} }
const { auth0Sub } = paramsResult.data; const { userId } = paramsResult.data;
const { role } = bodyResult.data; const { role } = bodyResult.data;
// Get the user profile first to verify they exist and get their email // Get the user profile to verify they exist and get their email
const user = await this.userProfileService.getUserDetails(auth0Sub); const user = await this.userProfileService.getUserDetails(userId);
if (!user) { if (!user) {
return reply.code(404).send({ return reply.code(404).send({
error: 'Not found', error: 'Not found',
@@ -591,12 +591,15 @@ export class UsersController {
}); });
} }
// Create the admin record using the user's real auth0Sub // Get actor's admin record for audit trail
const actorAdmin = await this.adminService.getAdminByUserProfileId(actorId);
// Create the admin record using the user's UUID
const adminUser = await this.adminService.createAdmin( const adminUser = await this.adminService.createAdmin(
user.email, user.email,
role, role,
auth0Sub, // Use the real auth0Sub from the user profile userId,
actorId actorAdmin?.id || actorId
); );
return reply.code(201).send(adminUser); return reply.code(201).send(adminUser);
@@ -605,7 +608,7 @@ export class UsersController {
logger.error('Error promoting user to admin', { logger.error('Error promoting user to admin', {
error: errorMessage, error: errorMessage,
auth0Sub: request.params?.auth0Sub, userId: (request.params as any)?.userId,
}); });
if (errorMessage.includes('already exists')) { if (errorMessage.includes('already exists')) {
@@ -623,10 +626,10 @@ export class UsersController {
} }
/** /**
* DELETE /api/admin/users/:auth0Sub - Hard delete user (permanent) * DELETE /api/admin/users/:userId - Hard delete user (permanent)
*/ */
async hardDeleteUser( async hardDeleteUser(
request: FastifyRequest<{ Params: UserAuth0SubInput }>, request: FastifyRequest<{ Params: UserIdInput }>,
reply: FastifyReply reply: FastifyReply
) { ) {
try { try {
@@ -639,7 +642,7 @@ export class UsersController {
} }
// Validate path param // Validate path param
const paramsResult = userAuth0SubSchema.safeParse(request.params); const paramsResult = userIdSchema.safeParse(request.params);
if (!paramsResult.success) { if (!paramsResult.success) {
return reply.code(400).send({ return reply.code(400).send({
error: 'Validation error', error: 'Validation error',
@@ -647,14 +650,14 @@ export class UsersController {
}); });
} }
const { auth0Sub } = paramsResult.data; const { userId } = paramsResult.data;
// Optional reason from query params // Optional reason from query params
const reason = (request.query as any)?.reason; const reason = (request.query as any)?.reason;
// Hard delete user // Hard delete user
await this.userProfileService.adminHardDeleteUser( await this.userProfileService.adminHardDeleteUser(
auth0Sub, userId,
actorId, actorId,
reason reason
); );
@@ -667,7 +670,7 @@ export class UsersController {
logger.error('Error hard deleting user', { logger.error('Error hard deleting user', {
error: errorMessage, error: errorMessage,
auth0Sub: request.params?.auth0Sub, userId: (request.params as any)?.userId,
}); });
if (errorMessage === 'Cannot delete your own account') { if (errorMessage === 'Cannot delete your own account') {

View File

@@ -19,9 +19,9 @@ export const listUsersQuerySchema = z.object({
sortOrder: z.enum(['asc', 'desc']).default('desc'), sortOrder: z.enum(['asc', 'desc']).default('desc'),
}); });
// Path param for user auth0Sub // Path param for user UUID
export const userAuth0SubSchema = z.object({ export const userIdSchema = z.object({
auth0Sub: z.string().min(1, 'auth0Sub is required'), userId: z.string().uuid('Invalid user ID format'),
}); });
// Body for updating subscription tier // Body for updating subscription tier
@@ -50,7 +50,7 @@ export const promoteToAdminSchema = z.object({
// Type exports // Type exports
export type ListUsersQueryInput = z.infer<typeof listUsersQuerySchema>; export type ListUsersQueryInput = z.infer<typeof listUsersQuerySchema>;
export type UserAuth0SubInput = z.infer<typeof userAuth0SubSchema>; export type UserIdInput = z.infer<typeof userIdSchema>;
export type UpdateTierInput = z.infer<typeof updateTierSchema>; export type UpdateTierInput = z.infer<typeof updateTierSchema>;
export type DeactivateUserInput = z.infer<typeof deactivateUserSchema>; export type DeactivateUserInput = z.infer<typeof deactivateUserSchema>;
export type UpdateProfileInput = z.infer<typeof updateProfileSchema>; export type UpdateProfileInput = z.infer<typeof updateProfileSchema>;

View File

@@ -10,29 +10,49 @@ import { logger } from '../../../core/logging/logger';
export class AdminRepository { export class AdminRepository {
constructor(private pool: Pool) {} constructor(private pool: Pool) {}
async getAdminByAuth0Sub(auth0Sub: string): Promise<AdminUser | null> { async getAdminById(id: string): Promise<AdminUser | null> {
const query = ` const query = `
SELECT auth0_sub, email, role, created_at, created_by, revoked_at, updated_at SELECT id, user_profile_id, email, role, created_at, created_by, revoked_at, updated_at
FROM admin_users FROM admin_users
WHERE auth0_sub = $1 WHERE id = $1
LIMIT 1 LIMIT 1
`; `;
try { try {
const result = await this.pool.query(query, [auth0Sub]); const result = await this.pool.query(query, [id]);
if (result.rows.length === 0) { if (result.rows.length === 0) {
return null; return null;
} }
return this.mapRowToAdminUser(result.rows[0]); return this.mapRowToAdminUser(result.rows[0]);
} catch (error) { } catch (error) {
logger.error('Error fetching admin by auth0_sub', { error, auth0Sub }); logger.error('Error fetching admin by id', { error, id });
throw error;
}
}
async getAdminByUserProfileId(userProfileId: string): Promise<AdminUser | null> {
const query = `
SELECT id, user_profile_id, email, role, created_at, created_by, revoked_at, updated_at
FROM admin_users
WHERE user_profile_id = $1
LIMIT 1
`;
try {
const result = await this.pool.query(query, [userProfileId]);
if (result.rows.length === 0) {
return null;
}
return this.mapRowToAdminUser(result.rows[0]);
} catch (error) {
logger.error('Error fetching admin by user_profile_id', { error, userProfileId });
throw error; throw error;
} }
} }
async getAdminByEmail(email: string): Promise<AdminUser | null> { async getAdminByEmail(email: string): Promise<AdminUser | null> {
const query = ` const query = `
SELECT auth0_sub, email, role, created_at, created_by, revoked_at, updated_at SELECT id, user_profile_id, email, role, created_at, created_by, revoked_at, updated_at
FROM admin_users FROM admin_users
WHERE LOWER(email) = LOWER($1) WHERE LOWER(email) = LOWER($1)
LIMIT 1 LIMIT 1
@@ -52,7 +72,7 @@ export class AdminRepository {
async getAllAdmins(): Promise<AdminUser[]> { async getAllAdmins(): Promise<AdminUser[]> {
const query = ` const query = `
SELECT auth0_sub, email, role, created_at, created_by, revoked_at, updated_at SELECT id, user_profile_id, email, role, created_at, created_by, revoked_at, updated_at
FROM admin_users FROM admin_users
ORDER BY created_at DESC ORDER BY created_at DESC
`; `;
@@ -68,7 +88,7 @@ export class AdminRepository {
async getActiveAdmins(): Promise<AdminUser[]> { async getActiveAdmins(): Promise<AdminUser[]> {
const query = ` const query = `
SELECT auth0_sub, email, role, created_at, created_by, revoked_at, updated_at SELECT id, user_profile_id, email, role, created_at, created_by, revoked_at, updated_at
FROM admin_users FROM admin_users
WHERE revoked_at IS NULL WHERE revoked_at IS NULL
ORDER BY created_at DESC ORDER BY created_at DESC
@@ -83,61 +103,61 @@ export class AdminRepository {
} }
} }
async createAdmin(auth0Sub: string, email: string, role: string, createdBy: string): Promise<AdminUser> { async createAdmin(userProfileId: string, email: string, role: string, createdBy: string): Promise<AdminUser> {
const query = ` const query = `
INSERT INTO admin_users (auth0_sub, email, role, created_by) INSERT INTO admin_users (user_profile_id, email, role, created_by)
VALUES ($1, $2, $3, $4) VALUES ($1, $2, $3, $4)
RETURNING auth0_sub, email, role, created_at, created_by, revoked_at, updated_at RETURNING id, user_profile_id, email, role, created_at, created_by, revoked_at, updated_at
`; `;
try { try {
const result = await this.pool.query(query, [auth0Sub, email, role, createdBy]); const result = await this.pool.query(query, [userProfileId, email, role, createdBy]);
if (result.rows.length === 0) { if (result.rows.length === 0) {
throw new Error('Failed to create admin user'); throw new Error('Failed to create admin user');
} }
return this.mapRowToAdminUser(result.rows[0]); return this.mapRowToAdminUser(result.rows[0]);
} catch (error) { } catch (error) {
logger.error('Error creating admin', { error, auth0Sub, email }); logger.error('Error creating admin', { error, userProfileId, email });
throw error; throw error;
} }
} }
async revokeAdmin(auth0Sub: string): Promise<AdminUser> { async revokeAdmin(id: string): Promise<AdminUser> {
const query = ` const query = `
UPDATE admin_users UPDATE admin_users
SET revoked_at = CURRENT_TIMESTAMP SET revoked_at = CURRENT_TIMESTAMP
WHERE auth0_sub = $1 WHERE id = $1
RETURNING auth0_sub, email, role, created_at, created_by, revoked_at, updated_at RETURNING id, user_profile_id, email, role, created_at, created_by, revoked_at, updated_at
`; `;
try { try {
const result = await this.pool.query(query, [auth0Sub]); const result = await this.pool.query(query, [id]);
if (result.rows.length === 0) { if (result.rows.length === 0) {
throw new Error('Admin user not found'); throw new Error('Admin user not found');
} }
return this.mapRowToAdminUser(result.rows[0]); return this.mapRowToAdminUser(result.rows[0]);
} catch (error) { } catch (error) {
logger.error('Error revoking admin', { error, auth0Sub }); logger.error('Error revoking admin', { error, id });
throw error; throw error;
} }
} }
async reinstateAdmin(auth0Sub: string): Promise<AdminUser> { async reinstateAdmin(id: string): Promise<AdminUser> {
const query = ` const query = `
UPDATE admin_users UPDATE admin_users
SET revoked_at = NULL SET revoked_at = NULL
WHERE auth0_sub = $1 WHERE id = $1
RETURNING auth0_sub, email, role, created_at, created_by, revoked_at, updated_at RETURNING id, user_profile_id, email, role, created_at, created_by, revoked_at, updated_at
`; `;
try { try {
const result = await this.pool.query(query, [auth0Sub]); const result = await this.pool.query(query, [id]);
if (result.rows.length === 0) { if (result.rows.length === 0) {
throw new Error('Admin user not found'); throw new Error('Admin user not found');
} }
return this.mapRowToAdminUser(result.rows[0]); return this.mapRowToAdminUser(result.rows[0]);
} catch (error) { } catch (error) {
logger.error('Error reinstating admin', { error, auth0Sub }); logger.error('Error reinstating admin', { error, id });
throw error; throw error;
} }
} }
@@ -202,30 +222,11 @@ export class AdminRepository {
} }
} }
async updateAuth0SubByEmail(email: string, auth0Sub: string): Promise<AdminUser> {
const query = `
UPDATE admin_users
SET auth0_sub = $1,
updated_at = CURRENT_TIMESTAMP
WHERE LOWER(email) = LOWER($2)
RETURNING auth0_sub, email, role, created_at, created_by, revoked_at, updated_at
`;
try {
const result = await this.pool.query(query, [auth0Sub, email]);
if (result.rows.length === 0) {
throw new Error(`Admin user with email ${email} not found`);
}
return this.mapRowToAdminUser(result.rows[0]);
} catch (error) {
logger.error('Error updating admin auth0_sub by email', { error, email, auth0Sub });
throw error;
}
}
private mapRowToAdminUser(row: any): AdminUser { private mapRowToAdminUser(row: any): AdminUser {
return { return {
auth0Sub: row.auth0_sub, id: row.id,
userProfileId: row.user_profile_id,
email: row.email, email: row.email,
role: row.role, role: row.role,
createdAt: new Date(row.created_at), createdAt: new Date(row.created_at),

View File

@@ -11,11 +11,20 @@ import { auditLogService } from '../../audit-log';
export class AdminService { export class AdminService {
constructor(private repository: AdminRepository) {} constructor(private repository: AdminRepository) {}
async getAdminByAuth0Sub(auth0Sub: string): Promise<AdminUser | null> { async getAdminById(id: string): Promise<AdminUser | null> {
try { try {
return await this.repository.getAdminByAuth0Sub(auth0Sub); return await this.repository.getAdminById(id);
} catch (error) { } catch (error) {
logger.error('Error getting admin by auth0_sub', { error }); logger.error('Error getting admin by id', { error });
throw error;
}
}
async getAdminByUserProfileId(userProfileId: string): Promise<AdminUser | null> {
try {
return await this.repository.getAdminByUserProfileId(userProfileId);
} catch (error) {
logger.error('Error getting admin by user_profile_id', { error });
throw error; throw error;
} }
} }
@@ -47,7 +56,7 @@ export class AdminService {
} }
} }
async createAdmin(email: string, role: string, auth0Sub: string, createdBy: string): Promise<AdminUser> { async createAdmin(email: string, role: string, userProfileId: string, createdByAdminId: string): Promise<AdminUser> {
try { try {
// Check if admin already exists // Check if admin already exists
const normalizedEmail = email.trim().toLowerCase(); const normalizedEmail = email.trim().toLowerCase();
@@ -57,10 +66,10 @@ export class AdminService {
} }
// Create new admin // Create new admin
const admin = await this.repository.createAdmin(auth0Sub, normalizedEmail, role, createdBy); const admin = await this.repository.createAdmin(userProfileId, normalizedEmail, role, createdByAdminId);
// Log audit action (legacy) // Log audit action (legacy)
await this.repository.logAuditAction(createdBy, 'CREATE', admin.auth0Sub, 'admin_user', admin.email, { await this.repository.logAuditAction(createdByAdminId, 'CREATE', admin.id, 'admin_user', admin.email, {
email, email,
role role
}); });
@@ -68,10 +77,10 @@ export class AdminService {
// Log to unified audit log // Log to unified audit log
await auditLogService.info( await auditLogService.info(
'admin', 'admin',
createdBy, userProfileId,
`Admin user created: ${admin.email}`, `Admin user created: ${admin.email}`,
'admin_user', 'admin_user',
admin.auth0Sub, admin.id,
{ email: admin.email, role } { email: admin.email, role }
).catch(err => logger.error('Failed to log admin create audit event', { error: err })); ).catch(err => logger.error('Failed to log admin create audit event', { error: err }));
@@ -83,7 +92,7 @@ export class AdminService {
} }
} }
async revokeAdmin(auth0Sub: string, revokedBy: string): Promise<AdminUser> { async revokeAdmin(id: string, revokedByAdminId: string): Promise<AdminUser> {
try { try {
// Check that at least one active admin will remain // Check that at least one active admin will remain
const activeAdmins = await this.repository.getActiveAdmins(); const activeAdmins = await this.repository.getActiveAdmins();
@@ -92,51 +101,51 @@ export class AdminService {
} }
// Revoke the admin // Revoke the admin
const admin = await this.repository.revokeAdmin(auth0Sub); const admin = await this.repository.revokeAdmin(id);
// Log audit action (legacy) // Log audit action (legacy)
await this.repository.logAuditAction(revokedBy, 'REVOKE', auth0Sub, 'admin_user', admin.email); await this.repository.logAuditAction(revokedByAdminId, 'REVOKE', id, 'admin_user', admin.email);
// Log to unified audit log // Log to unified audit log
await auditLogService.info( await auditLogService.info(
'admin', 'admin',
revokedBy, admin.userProfileId,
`Admin user revoked: ${admin.email}`, `Admin user revoked: ${admin.email}`,
'admin_user', 'admin_user',
auth0Sub, id,
{ email: admin.email } { email: admin.email }
).catch(err => logger.error('Failed to log admin revoke audit event', { error: err })); ).catch(err => logger.error('Failed to log admin revoke audit event', { error: err }));
logger.info('Admin user revoked', { auth0Sub, email: admin.email }); logger.info('Admin user revoked', { id, email: admin.email });
return admin; return admin;
} catch (error) { } catch (error) {
logger.error('Error revoking admin', { error, auth0Sub }); logger.error('Error revoking admin', { error, id });
throw error; throw error;
} }
} }
async reinstateAdmin(auth0Sub: string, reinstatedBy: string): Promise<AdminUser> { async reinstateAdmin(id: string, reinstatedByAdminId: string): Promise<AdminUser> {
try { try {
// Reinstate the admin // Reinstate the admin
const admin = await this.repository.reinstateAdmin(auth0Sub); const admin = await this.repository.reinstateAdmin(id);
// Log audit action (legacy) // Log audit action (legacy)
await this.repository.logAuditAction(reinstatedBy, 'REINSTATE', auth0Sub, 'admin_user', admin.email); await this.repository.logAuditAction(reinstatedByAdminId, 'REINSTATE', id, 'admin_user', admin.email);
// Log to unified audit log // Log to unified audit log
await auditLogService.info( await auditLogService.info(
'admin', 'admin',
reinstatedBy, admin.userProfileId,
`Admin user reinstated: ${admin.email}`, `Admin user reinstated: ${admin.email}`,
'admin_user', 'admin_user',
auth0Sub, id,
{ email: admin.email } { email: admin.email }
).catch(err => logger.error('Failed to log admin reinstate audit event', { error: err })); ).catch(err => logger.error('Failed to log admin reinstate audit event', { error: err }));
logger.info('Admin user reinstated', { auth0Sub, email: admin.email }); logger.info('Admin user reinstated', { id, email: admin.email });
return admin; return admin;
} catch (error) { } catch (error) {
logger.error('Error reinstating admin', { error, auth0Sub }); logger.error('Error reinstating admin', { error, id });
throw error; throw error;
} }
} }
@@ -150,12 +159,4 @@ export class AdminService {
} }
} }
async linkAdminAuth0Sub(email: string, auth0Sub: string): Promise<AdminUser> {
try {
return await this.repository.updateAuth0SubByEmail(email.trim().toLowerCase(), auth0Sub);
} catch (error) {
logger.error('Error linking admin auth0_sub to email', { error, email, auth0Sub });
throw error;
}
}
} }

View File

@@ -4,7 +4,8 @@
*/ */
export interface AdminUser { export interface AdminUser {
auth0Sub: string; id: string;
userProfileId: string;
email: string; email: string;
role: 'admin' | 'super_admin'; role: 'admin' | 'super_admin';
createdAt: Date; createdAt: Date;
@@ -19,11 +20,11 @@ export interface CreateAdminRequest {
} }
export interface RevokeAdminRequest { export interface RevokeAdminRequest {
auth0Sub: string; id: string;
} }
export interface ReinstateAdminRequest { export interface ReinstateAdminRequest {
auth0Sub: string; id: string;
} }
export interface AdminAuditLog { export interface AdminAuditLog {
@@ -71,25 +72,25 @@ export interface BulkCreateAdminResponse {
} }
export interface BulkRevokeAdminRequest { export interface BulkRevokeAdminRequest {
auth0Subs: string[]; ids: string[];
} }
export interface BulkRevokeAdminResponse { export interface BulkRevokeAdminResponse {
revoked: AdminUser[]; revoked: AdminUser[];
failed: Array<{ failed: Array<{
auth0Sub: string; id: string;
error: string; error: string;
}>; }>;
} }
export interface BulkReinstateAdminRequest { export interface BulkReinstateAdminRequest {
auth0Subs: string[]; ids: string[];
} }
export interface BulkReinstateAdminResponse { export interface BulkReinstateAdminResponse {
reinstated: AdminUser[]; reinstated: AdminUser[];
failed: Array<{ failed: Array<{
auth0Sub: string; id: string;
error: string; error: string;
}>; }>;
} }

View File

@@ -4,18 +4,19 @@
*/ */
import request from 'supertest'; import request from 'supertest';
import { app } from '../../../../app'; import { buildApp } from '../../../../app';
import pool from '../../../../core/config/database'; import pool from '../../../../core/config/database';
import { FastifyInstance } from 'fastify';
import { readFileSync } from 'fs'; import { readFileSync } from 'fs';
import { join } from 'path'; import { join } from 'path';
import fastifyPlugin from 'fastify-plugin'; import fastifyPlugin from 'fastify-plugin';
import { setAdminGuardPool } from '../../../../core/plugins/admin-guard.plugin'; import { setAdminGuardPool } from '../../../../core/plugins/admin-guard.plugin';
const DEFAULT_ADMIN_SUB = 'test-admin-123'; const DEFAULT_ADMIN_ID = '7c9e6679-7425-40de-944b-e07fc1f90ae7';
const DEFAULT_ADMIN_EMAIL = 'test-admin@motovaultpro.com'; const DEFAULT_ADMIN_EMAIL = 'test-admin@motovaultpro.com';
let currentUser = { let currentUser = {
sub: DEFAULT_ADMIN_SUB, sub: 'auth0|test-admin-123',
email: DEFAULT_ADMIN_EMAIL, email: DEFAULT_ADMIN_EMAIL,
}; };
@@ -25,11 +26,15 @@ jest.mock('../../../../core/plugins/auth.plugin', () => {
default: fastifyPlugin(async function(fastify) { default: fastifyPlugin(async function(fastify) {
fastify.decorate('authenticate', async function(request, _reply) { fastify.decorate('authenticate', async function(request, _reply) {
// Inject dynamic test user context // Inject dynamic test user context
// JWT sub is still auth0|xxx format
request.user = { sub: currentUser.sub }; request.user = { sub: currentUser.sub };
request.userContext = { request.userContext = {
userId: currentUser.sub, userId: DEFAULT_ADMIN_ID,
email: currentUser.email, email: currentUser.email,
emailVerified: true,
onboardingCompleted: true,
isAdmin: false, // Will be set by admin guard isAdmin: false, // Will be set by admin guard
subscriptionTier: 'free',
}; };
}); });
}, { name: 'auth-plugin' }) }, { name: 'auth-plugin' })
@@ -37,10 +42,14 @@ jest.mock('../../../../core/plugins/auth.plugin', () => {
}); });
describe('Admin Management Integration Tests', () => { describe('Admin Management Integration Tests', () => {
let testAdminAuth0Sub: string; let app: FastifyInstance;
let testNonAdminAuth0Sub: string; let testAdminId: string;
beforeAll(async () => { beforeAll(async () => {
// Build the app
app = await buildApp();
await app.ready();
// Run the admin migration directly using the migration file // Run the admin migration directly using the migration file
const migrationFile = join(__dirname, '../../migrations/001_create_admin_users.sql'); const migrationFile = join(__dirname, '../../migrations/001_create_admin_users.sql');
const migrationSQL = readFileSync(migrationFile, 'utf-8'); const migrationSQL = readFileSync(migrationFile, 'utf-8');
@@ -50,33 +59,31 @@ describe('Admin Management Integration Tests', () => {
setAdminGuardPool(pool); setAdminGuardPool(pool);
// Create test admin user // Create test admin user
testAdminAuth0Sub = DEFAULT_ADMIN_SUB; testAdminId = DEFAULT_ADMIN_ID;
await pool.query(` await pool.query(`
INSERT INTO admin_users (auth0_sub, email, role, created_by) INSERT INTO admin_users (id, user_profile_id, email, role, created_by)
VALUES ($1, $2, $3, $4) VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (auth0_sub) DO NOTHING ON CONFLICT (user_profile_id) DO NOTHING
`, [testAdminAuth0Sub, DEFAULT_ADMIN_EMAIL, 'admin', 'system']); `, [testAdminId, testAdminId, DEFAULT_ADMIN_EMAIL, 'admin', 'system']);
// Create test non-admin auth0Sub for permission tests
testNonAdminAuth0Sub = 'test-non-admin-456';
}); });
afterAll(async () => { afterAll(async () => {
// Clean up test database // Clean up test database
await pool.query('DROP TABLE IF EXISTS admin_audit_logs CASCADE'); await pool.query('DROP TABLE IF EXISTS admin_audit_logs CASCADE');
await pool.query('DROP TABLE IF EXISTS admin_users CASCADE'); await pool.query('DROP TABLE IF EXISTS admin_users CASCADE');
await app.close();
await pool.end(); await pool.end();
}); });
beforeEach(async () => { beforeEach(async () => {
// Clean up test data before each test (except the test admin) // Clean up test data before each test (except the test admin)
await pool.query( await pool.query(
'DELETE FROM admin_users WHERE auth0_sub != $1 AND auth0_sub != $2', 'DELETE FROM admin_users WHERE user_profile_id != $1',
[testAdminAuth0Sub, 'system|bootstrap'] [testAdminId]
); );
await pool.query('DELETE FROM admin_audit_logs'); await pool.query('DELETE FROM admin_audit_logs');
currentUser = { currentUser = {
sub: DEFAULT_ADMIN_SUB, sub: 'auth0|test-admin-123',
email: DEFAULT_ADMIN_EMAIL, email: DEFAULT_ADMIN_EMAIL,
}; };
}); });
@@ -85,11 +92,11 @@ describe('Admin Management Integration Tests', () => {
it('should reject non-admin user trying to list admins', async () => { it('should reject non-admin user trying to list admins', async () => {
// Create mock for non-admin user // Create mock for non-admin user
currentUser = { currentUser = {
sub: testNonAdminAuth0Sub, sub: 'auth0|test-non-admin-456',
email: 'test-user@example.com', email: 'test-user@example.com',
}; };
const response = await request(app) const response = await request(app.server)
.get('/api/admin/admins') .get('/api/admin/admins')
.expect(403); .expect(403);
@@ -101,51 +108,51 @@ describe('Admin Management Integration Tests', () => {
describe('GET /api/admin/verify', () => { describe('GET /api/admin/verify', () => {
it('should confirm admin access for existing admin', async () => { it('should confirm admin access for existing admin', async () => {
currentUser = { currentUser = {
sub: testAdminAuth0Sub, sub: 'auth0|test-admin-123',
email: DEFAULT_ADMIN_EMAIL, email: DEFAULT_ADMIN_EMAIL,
}; };
const response = await request(app) const response = await request(app.server)
.get('/api/admin/verify') .get('/api/admin/verify')
.expect(200); .expect(200);
expect(response.body.isAdmin).toBe(true); expect(response.body.isAdmin).toBe(true);
expect(response.body.adminRecord).toMatchObject({ expect(response.body.adminRecord).toMatchObject({
auth0Sub: testAdminAuth0Sub, id: testAdminId,
email: DEFAULT_ADMIN_EMAIL, email: DEFAULT_ADMIN_EMAIL,
}); });
}); });
it('should link admin record by email when auth0_sub differs', async () => { it('should link admin record by email when user_profile_id differs', async () => {
const placeholderSub = 'auth0|placeholder-sub'; const placeholderId = '9b9a1234-1234-1234-1234-123456789abc';
const realSub = 'auth0|real-admin-sub'; const realId = 'a1b2c3d4-5678-90ab-cdef-123456789def';
const email = 'link-admin@example.com'; const email = 'link-admin@example.com';
await pool.query(` await pool.query(`
INSERT INTO admin_users (auth0_sub, email, role, created_by) INSERT INTO admin_users (id, user_profile_id, email, role, created_by)
VALUES ($1, $2, $3, $4) VALUES ($1, $2, $3, $4, $5)
`, [placeholderSub, email, 'admin', testAdminAuth0Sub]); `, [placeholderId, placeholderId, email, 'admin', testAdminId]);
currentUser = { currentUser = {
sub: realSub, sub: 'auth0|real-admin-sub',
email, email,
}; };
const response = await request(app) const response = await request(app.server)
.get('/api/admin/verify') .get('/api/admin/verify')
.expect(200); .expect(200);
expect(response.body.isAdmin).toBe(true); expect(response.body.isAdmin).toBe(true);
expect(response.body.adminRecord).toMatchObject({ expect(response.body.adminRecord).toMatchObject({
auth0Sub: realSub, userProfileId: realId,
email, email,
}); });
const record = await pool.query( const record = await pool.query(
'SELECT auth0_sub FROM admin_users WHERE email = $1', 'SELECT user_profile_id FROM admin_users WHERE email = $1',
[email] [email]
); );
expect(record.rows[0].auth0_sub).toBe(realSub); expect(record.rows[0].user_profile_id).toBe(realId);
}); });
it('should return non-admin response for unknown user', async () => { it('should return non-admin response for unknown user', async () => {
@@ -154,7 +161,7 @@ describe('Admin Management Integration Tests', () => {
email: 'non-admin@example.com', email: 'non-admin@example.com',
}; };
const response = await request(app) const response = await request(app.server)
.get('/api/admin/verify') .get('/api/admin/verify')
.expect(200); .expect(200);
@@ -166,17 +173,19 @@ describe('Admin Management Integration Tests', () => {
describe('GET /api/admin/admins', () => { describe('GET /api/admin/admins', () => {
it('should list all admin users', async () => { it('should list all admin users', async () => {
// Create additional test admins // Create additional test admins
const admin1Id = '6ba7b810-9dad-11d1-80b4-00c04fd430c8';
const admin2Id = '8f14e45f-ceea-367f-a27f-c9a6d0c67e0e';
await pool.query(` await pool.query(`
INSERT INTO admin_users (auth0_sub, email, role, created_by) INSERT INTO admin_users (id, user_profile_id, email, role, created_by)
VALUES VALUES
($1, $2, $3, $4), ($1, $2, $3, $4, $5),
($5, $6, $7, $8) ($6, $7, $8, $9, $10)
`, [ `, [
'auth0|admin1', 'admin1@example.com', 'admin', testAdminAuth0Sub, admin1Id, admin1Id, 'admin1@example.com', 'admin', testAdminId,
'auth0|admin2', 'admin2@example.com', 'super_admin', testAdminAuth0Sub admin2Id, admin2Id, 'admin2@example.com', 'super_admin', testAdminId
]); ]);
const response = await request(app) const response = await request(app.server)
.get('/api/admin/admins') .get('/api/admin/admins')
.expect(200); .expect(200);
@@ -184,7 +193,7 @@ describe('Admin Management Integration Tests', () => {
expect(response.body).toHaveProperty('admins'); expect(response.body).toHaveProperty('admins');
expect(response.body.admins.length).toBeGreaterThanOrEqual(3); // At least test admin + 2 created expect(response.body.admins.length).toBeGreaterThanOrEqual(3); // At least test admin + 2 created
expect(response.body.admins[0]).toMatchObject({ expect(response.body.admins[0]).toMatchObject({
auth0Sub: expect.any(String), id: expect.any(String),
email: expect.any(String), email: expect.any(String),
role: expect.stringMatching(/^(admin|super_admin)$/), role: expect.stringMatching(/^(admin|super_admin)$/),
createdAt: expect.any(String), createdAt: expect.any(String),
@@ -194,12 +203,13 @@ describe('Admin Management Integration Tests', () => {
it('should include revoked admins in the list', async () => { it('should include revoked admins in the list', async () => {
// Create and revoke an admin // Create and revoke an admin
const revokedId = 'f1e2d3c4-b5a6-9788-6543-210fedcba987';
await pool.query(` await pool.query(`
INSERT INTO admin_users (auth0_sub, email, role, created_by, revoked_at) INSERT INTO admin_users (id, user_profile_id, email, role, created_by, revoked_at)
VALUES ($1, $2, $3, $4, CURRENT_TIMESTAMP) VALUES ($1, $2, $3, $4, $5, CURRENT_TIMESTAMP)
`, ['auth0|revoked', 'revoked@example.com', 'admin', testAdminAuth0Sub]); `, [revokedId, revokedId, 'revoked@example.com', 'admin', testAdminId]);
const response = await request(app) const response = await request(app.server)
.get('/api/admin/admins') .get('/api/admin/admins')
.expect(200); .expect(200);
@@ -218,17 +228,17 @@ describe('Admin Management Integration Tests', () => {
role: 'admin' role: 'admin'
}; };
const response = await request(app) const response = await request(app.server)
.post('/api/admin/admins') .post('/api/admin/admins')
.send(newAdminData) .send(newAdminData)
.expect(201); .expect(201);
expect(response.body).toMatchObject({ expect(response.body).toMatchObject({
auth0Sub: expect.any(String), id: expect.any(String),
email: 'newadmin@example.com', email: 'newadmin@example.com',
role: 'admin', role: 'admin',
createdAt: expect.any(String), createdAt: expect.any(String),
createdBy: testAdminAuth0Sub, createdBy: testAdminId,
revokedAt: null revokedAt: null
}); });
@@ -238,7 +248,7 @@ describe('Admin Management Integration Tests', () => {
['CREATE', 'newadmin@example.com'] ['CREATE', 'newadmin@example.com']
); );
expect(auditResult.rows.length).toBe(1); expect(auditResult.rows.length).toBe(1);
expect(auditResult.rows[0].actor_admin_id).toBe(testAdminAuth0Sub); expect(auditResult.rows[0].actor_admin_id).toBe(testAdminId);
}); });
it('should reject invalid email', async () => { it('should reject invalid email', async () => {
@@ -247,7 +257,7 @@ describe('Admin Management Integration Tests', () => {
role: 'admin' role: 'admin'
}; };
const response = await request(app) const response = await request(app.server)
.post('/api/admin/admins') .post('/api/admin/admins')
.send(invalidData) .send(invalidData)
.expect(400); .expect(400);
@@ -263,13 +273,13 @@ describe('Admin Management Integration Tests', () => {
}; };
// Create first admin // Create first admin
await request(app) await request(app.server)
.post('/api/admin/admins') .post('/api/admin/admins')
.send(adminData) .send(adminData)
.expect(201); .expect(201);
// Try to create duplicate // Try to create duplicate
const response = await request(app) const response = await request(app.server)
.post('/api/admin/admins') .post('/api/admin/admins')
.send(adminData) .send(adminData)
.expect(400); .expect(400);
@@ -284,7 +294,7 @@ describe('Admin Management Integration Tests', () => {
role: 'super_admin' role: 'super_admin'
}; };
const response = await request(app) const response = await request(app.server)
.post('/api/admin/admins') .post('/api/admin/admins')
.send(superAdminData) .send(superAdminData)
.expect(201); .expect(201);
@@ -297,7 +307,7 @@ describe('Admin Management Integration Tests', () => {
email: 'defaultrole@example.com' email: 'defaultrole@example.com'
}; };
const response = await request(app) const response = await request(app.server)
.post('/api/admin/admins') .post('/api/admin/admins')
.send(adminData) .send(adminData)
.expect(201); .expect(201);
@@ -306,23 +316,24 @@ describe('Admin Management Integration Tests', () => {
}); });
}); });
describe('PATCH /api/admin/admins/:auth0Sub/revoke', () => { describe('PATCH /api/admin/admins/:id/revoke', () => {
it('should revoke admin access', async () => { it('should revoke admin access', async () => {
// Create admin to revoke // Create admin to revoke
const toRevokeId = 'b1c2d3e4-f5a6-7890-1234-567890abcdef';
const createResult = await pool.query(` const createResult = await pool.query(`
INSERT INTO admin_users (auth0_sub, email, role, created_by) INSERT INTO admin_users (id, user_profile_id, email, role, created_by)
VALUES ($1, $2, $3, $4) VALUES ($1, $2, $3, $4, $5)
RETURNING auth0_sub RETURNING id
`, ['auth0|to-revoke', 'torevoke@example.com', 'admin', testAdminAuth0Sub]); `, [toRevokeId, toRevokeId, 'torevoke@example.com', 'admin', testAdminId]);
const auth0Sub = createResult.rows[0].auth0_sub; const adminId = createResult.rows[0].id;
const response = await request(app) const response = await request(app.server)
.patch(`/api/admin/admins/${auth0Sub}/revoke`) .patch(`/api/admin/admins/${adminId}/revoke`)
.expect(200); .expect(200);
expect(response.body).toMatchObject({ expect(response.body).toMatchObject({
auth0Sub, id: adminId,
email: 'torevoke@example.com', email: 'torevoke@example.com',
revokedAt: expect.any(String) revokedAt: expect.any(String)
}); });
@@ -330,7 +341,7 @@ describe('Admin Management Integration Tests', () => {
// Verify audit log // Verify audit log
const auditResult = await pool.query( const auditResult = await pool.query(
'SELECT * FROM admin_audit_logs WHERE action = $1 AND target_admin_id = $2', 'SELECT * FROM admin_audit_logs WHERE action = $1 AND target_admin_id = $2',
['REVOKE', auth0Sub] ['REVOKE', adminId]
); );
expect(auditResult.rows.length).toBe(1); expect(auditResult.rows.length).toBe(1);
}); });
@@ -338,12 +349,12 @@ describe('Admin Management Integration Tests', () => {
it('should prevent revoking last active admin', async () => { it('should prevent revoking last active admin', async () => {
// First, ensure only one active admin exists // First, ensure only one active admin exists
await pool.query( await pool.query(
'UPDATE admin_users SET revoked_at = CURRENT_TIMESTAMP WHERE auth0_sub != $1', 'UPDATE admin_users SET revoked_at = CURRENT_TIMESTAMP WHERE user_profile_id != $1',
[testAdminAuth0Sub] [testAdminId]
); );
const response = await request(app) const response = await request(app.server)
.patch(`/api/admin/admins/${testAdminAuth0Sub}/revoke`) .patch(`/api/admin/admins/${testAdminId}/revoke`)
.expect(400); .expect(400);
expect(response.body.error).toBe('Bad Request'); expect(response.body.error).toBe('Bad Request');
@@ -351,8 +362,8 @@ describe('Admin Management Integration Tests', () => {
}); });
it('should return 404 for non-existent admin', async () => { it('should return 404 for non-existent admin', async () => {
const response = await request(app) const response = await request(app.server)
.patch('/api/admin/admins/auth0|nonexistent/revoke') .patch('/api/admin/admins/00000000-0000-0000-0000-000000000000/revoke')
.expect(404); .expect(404);
expect(response.body.error).toBe('Not Found'); expect(response.body.error).toBe('Not Found');
@@ -360,23 +371,24 @@ describe('Admin Management Integration Tests', () => {
}); });
}); });
describe('PATCH /api/admin/admins/:auth0Sub/reinstate', () => { describe('PATCH /api/admin/admins/:id/reinstate', () => {
it('should reinstate revoked admin', async () => { it('should reinstate revoked admin', async () => {
// Create revoked admin // Create revoked admin
const reinstateId = 'c2d3e4f5-a6b7-8901-2345-678901bcdef0';
const createResult = await pool.query(` const createResult = await pool.query(`
INSERT INTO admin_users (auth0_sub, email, role, created_by, revoked_at) INSERT INTO admin_users (id, user_profile_id, email, role, created_by, revoked_at)
VALUES ($1, $2, $3, $4, CURRENT_TIMESTAMP) VALUES ($1, $2, $3, $4, $5, CURRENT_TIMESTAMP)
RETURNING auth0_sub RETURNING id
`, ['auth0|to-reinstate', 'toreinstate@example.com', 'admin', testAdminAuth0Sub]); `, [reinstateId, reinstateId, 'toreinstate@example.com', 'admin', testAdminId]);
const auth0Sub = createResult.rows[0].auth0_sub; const adminId = createResult.rows[0].id;
const response = await request(app) const response = await request(app.server)
.patch(`/api/admin/admins/${auth0Sub}/reinstate`) .patch(`/api/admin/admins/${adminId}/reinstate`)
.expect(200); .expect(200);
expect(response.body).toMatchObject({ expect(response.body).toMatchObject({
auth0Sub, id: adminId,
email: 'toreinstate@example.com', email: 'toreinstate@example.com',
revokedAt: null revokedAt: null
}); });
@@ -384,14 +396,14 @@ describe('Admin Management Integration Tests', () => {
// Verify audit log // Verify audit log
const auditResult = await pool.query( const auditResult = await pool.query(
'SELECT * FROM admin_audit_logs WHERE action = $1 AND target_admin_id = $2', 'SELECT * FROM admin_audit_logs WHERE action = $1 AND target_admin_id = $2',
['REINSTATE', auth0Sub] ['REINSTATE', adminId]
); );
expect(auditResult.rows.length).toBe(1); expect(auditResult.rows.length).toBe(1);
}); });
it('should return 404 for non-existent admin', async () => { it('should return 404 for non-existent admin', async () => {
const response = await request(app) const response = await request(app.server)
.patch('/api/admin/admins/auth0|nonexistent/reinstate') .patch('/api/admin/admins/00000000-0000-0000-0000-000000000000/reinstate')
.expect(404); .expect(404);
expect(response.body.error).toBe('Not Found'); expect(response.body.error).toBe('Not Found');
@@ -400,16 +412,17 @@ describe('Admin Management Integration Tests', () => {
it('should handle reinstating already active admin', async () => { it('should handle reinstating already active admin', async () => {
// Create active admin // Create active admin
const activeId = 'd3e4f5a6-b7c8-9012-3456-789012cdef01';
const createResult = await pool.query(` const createResult = await pool.query(`
INSERT INTO admin_users (auth0_sub, email, role, created_by) INSERT INTO admin_users (id, user_profile_id, email, role, created_by)
VALUES ($1, $2, $3, $4) VALUES ($1, $2, $3, $4, $5)
RETURNING auth0_sub RETURNING id
`, ['auth0|already-active', 'active@example.com', 'admin', testAdminAuth0Sub]); `, [activeId, activeId, 'active@example.com', 'admin', testAdminId]);
const auth0Sub = createResult.rows[0].auth0_sub; const adminId = createResult.rows[0].id;
const response = await request(app) const response = await request(app.server)
.patch(`/api/admin/admins/${auth0Sub}/reinstate`) .patch(`/api/admin/admins/${adminId}/reinstate`)
.expect(200); .expect(200);
expect(response.body.revokedAt).toBeNull(); expect(response.body.revokedAt).toBeNull();
@@ -426,12 +439,12 @@ describe('Admin Management Integration Tests', () => {
($5, $6, $7, $8), ($5, $6, $7, $8),
($9, $10, $11, $12) ($9, $10, $11, $12)
`, [ `, [
testAdminAuth0Sub, 'CREATE', 'admin_user', 'test1@example.com', testAdminId, 'CREATE', 'admin_user', 'test1@example.com',
testAdminAuth0Sub, 'REVOKE', 'admin_user', 'test2@example.com', testAdminId, 'REVOKE', 'admin_user', 'test2@example.com',
testAdminAuth0Sub, 'REINSTATE', 'admin_user', 'test3@example.com' testAdminId, 'REINSTATE', 'admin_user', 'test3@example.com'
]); ]);
const response = await request(app) const response = await request(app.server)
.get('/api/admin/audit-logs') .get('/api/admin/audit-logs')
.expect(200); .expect(200);
@@ -440,7 +453,7 @@ describe('Admin Management Integration Tests', () => {
expect(response.body.logs.length).toBeGreaterThanOrEqual(3); expect(response.body.logs.length).toBeGreaterThanOrEqual(3);
expect(response.body.logs[0]).toMatchObject({ expect(response.body.logs[0]).toMatchObject({
id: expect.any(String), id: expect.any(String),
actorAdminId: testAdminAuth0Sub, actorAdminId: testAdminId,
action: expect.any(String), action: expect.any(String),
resourceType: expect.any(String), resourceType: expect.any(String),
createdAt: expect.any(String) createdAt: expect.any(String)
@@ -453,10 +466,10 @@ describe('Admin Management Integration Tests', () => {
await pool.query(` await pool.query(`
INSERT INTO admin_audit_logs (actor_admin_id, action, resource_type, resource_id) INSERT INTO admin_audit_logs (actor_admin_id, action, resource_type, resource_id)
VALUES ($1, $2, $3, $4) VALUES ($1, $2, $3, $4)
`, [testAdminAuth0Sub, 'CREATE', 'admin_user', `test${i}@example.com`]); `, [testAdminId, 'CREATE', 'admin_user', `test${i}@example.com`]);
} }
const response = await request(app) const response = await request(app.server)
.get('/api/admin/audit-logs?limit=5&offset=0') .get('/api/admin/audit-logs?limit=5&offset=0')
.expect(200); .expect(200);
@@ -473,12 +486,12 @@ describe('Admin Management Integration Tests', () => {
($3, $4, CURRENT_TIMESTAMP - INTERVAL '1 minute'), ($3, $4, CURRENT_TIMESTAMP - INTERVAL '1 minute'),
($5, $6, CURRENT_TIMESTAMP) ($5, $6, CURRENT_TIMESTAMP)
`, [ `, [
testAdminAuth0Sub, 'FIRST', testAdminId, 'FIRST',
testAdminAuth0Sub, 'SECOND', testAdminId, 'SECOND',
testAdminAuth0Sub, 'THIRD' testAdminId, 'THIRD'
]); ]);
const response = await request(app) const response = await request(app.server)
.get('/api/admin/audit-logs?limit=3') .get('/api/admin/audit-logs?limit=3')
.expect(200); .expect(200);
@@ -491,45 +504,45 @@ describe('Admin Management Integration Tests', () => {
describe('End-to-end workflow', () => { describe('End-to-end workflow', () => {
it('should create, revoke, and reinstate admin with full audit trail', async () => { it('should create, revoke, and reinstate admin with full audit trail', async () => {
// 1. Create new admin // 1. Create new admin
const createResponse = await request(app) const createResponse = await request(app.server)
.post('/api/admin/admins') .post('/api/admin/admins')
.send({ email: 'workflow@example.com', role: 'admin' }) .send({ email: 'workflow@example.com', role: 'admin' })
.expect(201); .expect(201);
const auth0Sub = createResponse.body.auth0Sub; const adminId = createResponse.body.id;
// 2. Verify admin appears in list // 2. Verify admin appears in list
const listResponse = await request(app) const listResponse = await request(app.server)
.get('/api/admin/admins') .get('/api/admin/admins')
.expect(200); .expect(200);
const createdAdmin = listResponse.body.admins.find( const createdAdmin = listResponse.body.admins.find(
(admin: any) => admin.auth0Sub === auth0Sub (admin: any) => admin.id === adminId
); );
expect(createdAdmin).toBeDefined(); expect(createdAdmin).toBeDefined();
expect(createdAdmin.revokedAt).toBeNull(); expect(createdAdmin.revokedAt).toBeNull();
// 3. Revoke admin // 3. Revoke admin
const revokeResponse = await request(app) const revokeResponse = await request(app.server)
.patch(`/api/admin/admins/${auth0Sub}/revoke`) .patch(`/api/admin/admins/${adminId}/revoke`)
.expect(200); .expect(200);
expect(revokeResponse.body.revokedAt).toBeTruthy(); expect(revokeResponse.body.revokedAt).toBeTruthy();
// 4. Reinstate admin // 4. Reinstate admin
const reinstateResponse = await request(app) const reinstateResponse = await request(app.server)
.patch(`/api/admin/admins/${auth0Sub}/reinstate`) .patch(`/api/admin/admins/${adminId}/reinstate`)
.expect(200); .expect(200);
expect(reinstateResponse.body.revokedAt).toBeNull(); expect(reinstateResponse.body.revokedAt).toBeNull();
// 5. Verify complete audit trail // 5. Verify complete audit trail
const auditResponse = await request(app) const auditResponse = await request(app.server)
.get('/api/admin/audit-logs') .get('/api/admin/audit-logs')
.expect(200); .expect(200);
const workflowLogs = auditResponse.body.logs.filter( const workflowLogs = auditResponse.body.logs.filter(
(log: any) => log.targetAdminId === auth0Sub || log.resourceId === 'workflow@example.com' (log: any) => log.targetAdminId === adminId || log.resourceId === 'workflow@example.com'
); );
expect(workflowLogs.length).toBeGreaterThanOrEqual(3); expect(workflowLogs.length).toBeGreaterThanOrEqual(3);

View File

@@ -26,7 +26,7 @@ describe('admin guard plugin', () => {
fastify = Fastify(); fastify = Fastify();
authenticateMock = jest.fn(async (request: FastifyRequest) => { authenticateMock = jest.fn(async (request: FastifyRequest) => {
request.userContext = { request.userContext = {
userId: 'auth0|admin', userId: '7c9e6679-7425-40de-944b-e07fc1f90ae7',
email: 'admin@motovaultpro.com', email: 'admin@motovaultpro.com',
emailVerified: true, emailVerified: true,
onboardingCompleted: true, onboardingCompleted: true,
@@ -41,7 +41,7 @@ describe('admin guard plugin', () => {
mockPool = { mockPool = {
query: jest.fn().mockResolvedValue({ query: jest.fn().mockResolvedValue({
rows: [{ rows: [{
auth0_sub: 'auth0|admin', user_profile_id: '7c9e6679-7425-40de-944b-e07fc1f90ae7',
email: 'admin@motovaultpro.com', email: 'admin@motovaultpro.com',
role: 'admin', role: 'admin',
revoked_at: null, revoked_at: null,

View File

@@ -6,13 +6,23 @@
import { AdminService } from '../../domain/admin.service'; import { AdminService } from '../../domain/admin.service';
import { AdminRepository } from '../../data/admin.repository'; import { AdminRepository } from '../../data/admin.repository';
// Mock the audit log service
jest.mock('../../../audit-log', () => ({
auditLogService: {
info: jest.fn().mockResolvedValue(undefined),
warn: jest.fn().mockResolvedValue(undefined),
error: jest.fn().mockResolvedValue(undefined),
},
}));
describe('AdminService', () => { describe('AdminService', () => {
let adminService: AdminService; let adminService: AdminService;
let mockRepository: jest.Mocked<AdminRepository>; let mockRepository: jest.Mocked<AdminRepository>;
beforeEach(() => { beforeEach(() => {
mockRepository = { mockRepository = {
getAdminByAuth0Sub: jest.fn(), getAdminById: jest.fn(),
getAdminByUserProfileId: jest.fn(),
getAdminByEmail: jest.fn(), getAdminByEmail: jest.fn(),
getAllAdmins: jest.fn(), getAllAdmins: jest.fn(),
getActiveAdmins: jest.fn(), getActiveAdmins: jest.fn(),
@@ -26,30 +36,31 @@ describe('AdminService', () => {
adminService = new AdminService(mockRepository); adminService = new AdminService(mockRepository);
}); });
describe('getAdminByAuth0Sub', () => { describe('getAdminById', () => {
it('should return admin when found', async () => { it('should return admin when found', async () => {
const mockAdmin = { const mockAdmin = {
auth0Sub: 'auth0|123456', id: '7c9e6679-7425-40de-944b-e07fc1f90ae7',
userProfileId: '7c9e6679-7425-40de-944b-e07fc1f90ae7',
email: 'admin@motovaultpro.com', email: 'admin@motovaultpro.com',
role: 'admin', role: 'admin' as const,
createdAt: new Date(), createdAt: new Date(),
createdBy: 'system', createdBy: '550e8400-e29b-41d4-a716-446655440000',
revokedAt: null, revokedAt: null,
updatedAt: new Date(), updatedAt: new Date(),
}; };
mockRepository.getAdminByAuth0Sub.mockResolvedValue(mockAdmin); mockRepository.getAdminById.mockResolvedValue(mockAdmin);
const result = await adminService.getAdminByAuth0Sub('auth0|123456'); const result = await adminService.getAdminById('7c9e6679-7425-40de-944b-e07fc1f90ae7');
expect(result).toEqual(mockAdmin); expect(result).toEqual(mockAdmin);
expect(mockRepository.getAdminByAuth0Sub).toHaveBeenCalledWith('auth0|123456'); expect(mockRepository.getAdminById).toHaveBeenCalledWith('7c9e6679-7425-40de-944b-e07fc1f90ae7');
}); });
it('should return null when admin not found', async () => { it('should return null when admin not found', async () => {
mockRepository.getAdminByAuth0Sub.mockResolvedValue(null); mockRepository.getAdminById.mockResolvedValue(null);
const result = await adminService.getAdminByAuth0Sub('auth0|unknown'); const result = await adminService.getAdminById('00000000-0000-0000-0000-000000000000');
expect(result).toBeNull(); expect(result).toBeNull();
}); });
@@ -57,12 +68,15 @@ describe('AdminService', () => {
describe('createAdmin', () => { describe('createAdmin', () => {
it('should create new admin and log audit', async () => { it('should create new admin and log audit', async () => {
const newAdminId = '8f14e45f-ceea-367f-a27f-c9a6d0c67e0e';
const creatorId = '7c9e6679-7425-40de-944b-e07fc1f90ae7';
const mockAdmin = { const mockAdmin = {
auth0Sub: 'auth0|newadmin', id: newAdminId,
userProfileId: newAdminId,
email: 'newadmin@motovaultpro.com', email: 'newadmin@motovaultpro.com',
role: 'admin', role: 'admin' as const,
createdAt: new Date(), createdAt: new Date(),
createdBy: 'auth0|existing', createdBy: creatorId,
revokedAt: null, revokedAt: null,
updatedAt: new Date(), updatedAt: new Date(),
}; };
@@ -74,16 +88,16 @@ describe('AdminService', () => {
const result = await adminService.createAdmin( const result = await adminService.createAdmin(
'newadmin@motovaultpro.com', 'newadmin@motovaultpro.com',
'admin', 'admin',
'auth0|newadmin', newAdminId,
'auth0|existing' creatorId
); );
expect(result).toEqual(mockAdmin); expect(result).toEqual(mockAdmin);
expect(mockRepository.createAdmin).toHaveBeenCalled(); expect(mockRepository.createAdmin).toHaveBeenCalled();
expect(mockRepository.logAuditAction).toHaveBeenCalledWith( expect(mockRepository.logAuditAction).toHaveBeenCalledWith(
'auth0|existing', creatorId,
'CREATE', 'CREATE',
mockAdmin.auth0Sub, mockAdmin.id,
'admin_user', 'admin_user',
mockAdmin.email, mockAdmin.email,
expect.any(Object) expect.any(Object)
@@ -91,12 +105,14 @@ describe('AdminService', () => {
}); });
it('should reject if admin already exists', async () => { it('should reject if admin already exists', async () => {
const existingId = '7c9e6679-7425-40de-944b-e07fc1f90ae7';
const existingAdmin = { const existingAdmin = {
auth0Sub: 'auth0|existing', id: existingId,
userProfileId: existingId,
email: 'admin@motovaultpro.com', email: 'admin@motovaultpro.com',
role: 'admin', role: 'admin' as const,
createdAt: new Date(), createdAt: new Date(),
createdBy: 'system', createdBy: '550e8400-e29b-41d4-a716-446655440000',
revokedAt: null, revokedAt: null,
updatedAt: new Date(), updatedAt: new Date(),
}; };
@@ -104,39 +120,46 @@ describe('AdminService', () => {
mockRepository.getAdminByEmail.mockResolvedValue(existingAdmin); mockRepository.getAdminByEmail.mockResolvedValue(existingAdmin);
await expect( await expect(
adminService.createAdmin('admin@motovaultpro.com', 'admin', 'auth0|new', 'auth0|existing') adminService.createAdmin('admin@motovaultpro.com', 'admin', '8f14e45f-ceea-367f-a27f-c9a6d0c67e0e', existingId)
).rejects.toThrow('already exists'); ).rejects.toThrow('already exists');
}); });
}); });
describe('revokeAdmin', () => { describe('revokeAdmin', () => {
it('should revoke admin when multiple active admins exist', async () => { it('should revoke admin when multiple active admins exist', async () => {
const toRevokeId = 'a1b2c3d4-e5f6-7890-1234-567890abcdef';
const admin1Id = '6ba7b810-9dad-11d1-80b4-00c04fd430c8';
const admin2Id = '8f14e45f-ceea-367f-a27f-c9a6d0c67e0e';
const revokedAdmin = { const revokedAdmin = {
auth0Sub: 'auth0|toadmin', id: toRevokeId,
userProfileId: toRevokeId,
email: 'toadmin@motovaultpro.com', email: 'toadmin@motovaultpro.com',
role: 'admin', role: 'admin' as const,
createdAt: new Date(), createdAt: new Date(),
createdBy: 'system', createdBy: '550e8400-e29b-41d4-a716-446655440000',
revokedAt: new Date(), revokedAt: new Date(),
updatedAt: new Date(), updatedAt: new Date(),
}; };
const activeAdmins = [ const activeAdmins = [
{ {
auth0Sub: 'auth0|admin1', id: admin1Id,
userProfileId: admin1Id,
email: 'admin1@motovaultpro.com', email: 'admin1@motovaultpro.com',
role: 'admin', role: 'admin' as const,
createdAt: new Date(), createdAt: new Date(),
createdBy: 'system', createdBy: '550e8400-e29b-41d4-a716-446655440000',
revokedAt: null, revokedAt: null,
updatedAt: new Date(), updatedAt: new Date(),
}, },
{ {
auth0Sub: 'auth0|admin2', id: admin2Id,
userProfileId: admin2Id,
email: 'admin2@motovaultpro.com', email: 'admin2@motovaultpro.com',
role: 'admin', role: 'admin' as const,
createdAt: new Date(), createdAt: new Date(),
createdBy: 'system', createdBy: '550e8400-e29b-41d4-a716-446655440000',
revokedAt: null, revokedAt: null,
updatedAt: new Date(), updatedAt: new Date(),
}, },
@@ -146,20 +169,22 @@ describe('AdminService', () => {
mockRepository.revokeAdmin.mockResolvedValue(revokedAdmin); mockRepository.revokeAdmin.mockResolvedValue(revokedAdmin);
mockRepository.logAuditAction.mockResolvedValue({} as any); mockRepository.logAuditAction.mockResolvedValue({} as any);
const result = await adminService.revokeAdmin('auth0|toadmin', 'auth0|admin1'); const result = await adminService.revokeAdmin(toRevokeId, admin1Id);
expect(result).toEqual(revokedAdmin); expect(result).toEqual(revokedAdmin);
expect(mockRepository.revokeAdmin).toHaveBeenCalledWith('auth0|toadmin'); expect(mockRepository.revokeAdmin).toHaveBeenCalledWith(toRevokeId);
expect(mockRepository.logAuditAction).toHaveBeenCalled(); expect(mockRepository.logAuditAction).toHaveBeenCalled();
}); });
it('should prevent revoking last active admin', async () => { it('should prevent revoking last active admin', async () => {
const lastAdminId = '7c9e6679-7425-40de-944b-e07fc1f90ae7';
const lastAdmin = { const lastAdmin = {
auth0Sub: 'auth0|lastadmin', id: lastAdminId,
userProfileId: lastAdminId,
email: 'last@motovaultpro.com', email: 'last@motovaultpro.com',
role: 'admin', role: 'admin' as const,
createdAt: new Date(), createdAt: new Date(),
createdBy: 'system', createdBy: '550e8400-e29b-41d4-a716-446655440000',
revokedAt: null, revokedAt: null,
updatedAt: new Date(), updatedAt: new Date(),
}; };
@@ -167,19 +192,22 @@ describe('AdminService', () => {
mockRepository.getActiveAdmins.mockResolvedValue([lastAdmin]); mockRepository.getActiveAdmins.mockResolvedValue([lastAdmin]);
await expect( await expect(
adminService.revokeAdmin('auth0|lastadmin', 'auth0|lastadmin') adminService.revokeAdmin(lastAdminId, lastAdminId)
).rejects.toThrow('Cannot revoke the last active admin'); ).rejects.toThrow('Cannot revoke the last active admin');
}); });
}); });
describe('reinstateAdmin', () => { describe('reinstateAdmin', () => {
it('should reinstate revoked admin and log audit', async () => { it('should reinstate revoked admin and log audit', async () => {
const reinstateId = 'b2c3d4e5-f6a7-8901-2345-678901bcdef0';
const adminActorId = '7c9e6679-7425-40de-944b-e07fc1f90ae7';
const reinstatedAdmin = { const reinstatedAdmin = {
auth0Sub: 'auth0|reinstate', id: reinstateId,
userProfileId: reinstateId,
email: 'reinstate@motovaultpro.com', email: 'reinstate@motovaultpro.com',
role: 'admin', role: 'admin' as const,
createdAt: new Date(), createdAt: new Date(),
createdBy: 'system', createdBy: '550e8400-e29b-41d4-a716-446655440000',
revokedAt: null, revokedAt: null,
updatedAt: new Date(), updatedAt: new Date(),
}; };
@@ -187,14 +215,14 @@ describe('AdminService', () => {
mockRepository.reinstateAdmin.mockResolvedValue(reinstatedAdmin); mockRepository.reinstateAdmin.mockResolvedValue(reinstatedAdmin);
mockRepository.logAuditAction.mockResolvedValue({} as any); mockRepository.logAuditAction.mockResolvedValue({} as any);
const result = await adminService.reinstateAdmin('auth0|reinstate', 'auth0|admin'); const result = await adminService.reinstateAdmin(reinstateId, adminActorId);
expect(result).toEqual(reinstatedAdmin); expect(result).toEqual(reinstatedAdmin);
expect(mockRepository.reinstateAdmin).toHaveBeenCalledWith('auth0|reinstate'); expect(mockRepository.reinstateAdmin).toHaveBeenCalledWith(reinstateId);
expect(mockRepository.logAuditAction).toHaveBeenCalledWith( expect(mockRepository.logAuditAction).toHaveBeenCalledWith(
'auth0|admin', adminActorId,
'REINSTATE', 'REINSTATE',
'auth0|reinstate', reinstateId,
'admin_user', 'admin_user',
reinstatedAdmin.email reinstatedAdmin.email
); );

View File

@@ -32,7 +32,7 @@ describe('AuditLog Feature Integration', () => {
describe('Vehicle logging integration', () => { describe('Vehicle logging integration', () => {
it('should create audit log with vehicle category and correct resource', async () => { it('should create audit log with vehicle category and correct resource', async () => {
const userId = 'test-user-vehicle-123'; const userId = '550e8400-e29b-41d4-a716-446655440000';
const vehicleId = 'vehicle-uuid-123'; const vehicleId = 'vehicle-uuid-123';
const entry = await service.info( const entry = await service.info(
'vehicle', 'vehicle',
@@ -56,7 +56,7 @@ describe('AuditLog Feature Integration', () => {
}); });
it('should log vehicle update with correct fields', async () => { it('should log vehicle update with correct fields', async () => {
const userId = 'test-user-vehicle-456'; const userId = '6ba7b810-9dad-11d1-80b4-00c04fd430c8';
const vehicleId = 'vehicle-uuid-456'; const vehicleId = 'vehicle-uuid-456';
const entry = await service.info( const entry = await service.info(
'vehicle', 'vehicle',
@@ -75,7 +75,7 @@ describe('AuditLog Feature Integration', () => {
}); });
it('should log vehicle deletion with vehicle info', async () => { it('should log vehicle deletion with vehicle info', async () => {
const userId = 'test-user-vehicle-789'; const userId = '7c9e6679-7425-40de-944b-e07fc1f90ae7';
const vehicleId = 'vehicle-uuid-789'; const vehicleId = 'vehicle-uuid-789';
const entry = await service.info( const entry = await service.info(
'vehicle', 'vehicle',
@@ -96,7 +96,7 @@ describe('AuditLog Feature Integration', () => {
describe('Auth logging integration', () => { describe('Auth logging integration', () => {
it('should create audit log with auth category for signup', async () => { it('should create audit log with auth category for signup', async () => {
const userId = 'test-user-auth-123'; const userId = '550e8400-e29b-41d4-a716-446655440000';
const entry = await service.info( const entry = await service.info(
'auth', 'auth',
userId, userId,
@@ -116,7 +116,7 @@ describe('AuditLog Feature Integration', () => {
}); });
it('should create audit log for password reset request', async () => { it('should create audit log for password reset request', async () => {
const userId = 'test-user-auth-456'; const userId = '6ba7b810-9dad-11d1-80b4-00c04fd430c8';
const entry = await service.info( const entry = await service.info(
'auth', 'auth',
userId, userId,
@@ -134,14 +134,14 @@ describe('AuditLog Feature Integration', () => {
describe('Admin logging integration', () => { describe('Admin logging integration', () => {
it('should create audit log for admin user creation', async () => { it('should create audit log for admin user creation', async () => {
const adminId = 'admin-user-123'; const adminId = '7c9e6679-7425-40de-944b-e07fc1f90ae7';
const targetAdminSub = 'auth0|target-admin-456'; const targetAdminId = '8f14e45f-ceea-367f-a27f-c9a6d0c67e0e';
const entry = await service.info( const entry = await service.info(
'admin', 'admin',
adminId, adminId,
'Admin user created: newadmin@example.com', 'Admin user created: newadmin@example.com',
'admin_user', 'admin_user',
targetAdminSub, targetAdminId,
{ email: 'newadmin@example.com', role: 'admin' } { email: 'newadmin@example.com', role: 'admin' }
); );
@@ -156,14 +156,14 @@ describe('AuditLog Feature Integration', () => {
}); });
it('should create audit log for admin revocation', async () => { it('should create audit log for admin revocation', async () => {
const adminId = 'admin-user-123'; const adminId = '7c9e6679-7425-40de-944b-e07fc1f90ae7';
const targetAdminSub = 'auth0|target-admin-789'; const targetAdminId = 'a1b2c3d4-e5f6-7890-1234-567890abcdef';
const entry = await service.info( const entry = await service.info(
'admin', 'admin',
adminId, adminId,
'Admin user revoked: revoked@example.com', 'Admin user revoked: revoked@example.com',
'admin_user', 'admin_user',
targetAdminSub, targetAdminId,
{ email: 'revoked@example.com' } { email: 'revoked@example.com' }
); );
@@ -174,14 +174,14 @@ describe('AuditLog Feature Integration', () => {
}); });
it('should create audit log for admin reinstatement', async () => { it('should create audit log for admin reinstatement', async () => {
const adminId = 'admin-user-123'; const adminId = '7c9e6679-7425-40de-944b-e07fc1f90ae7';
const targetAdminSub = 'auth0|target-admin-reinstated'; const targetAdminId = 'b2c3d4e5-f6a7-8901-2345-678901bcdef0';
const entry = await service.info( const entry = await service.info(
'admin', 'admin',
adminId, adminId,
'Admin user reinstated: reinstated@example.com', 'Admin user reinstated: reinstated@example.com',
'admin_user', 'admin_user',
targetAdminSub, targetAdminId,
{ email: 'reinstated@example.com' } { email: 'reinstated@example.com' }
); );
@@ -194,7 +194,7 @@ describe('AuditLog Feature Integration', () => {
describe('Backup/System logging integration', () => { describe('Backup/System logging integration', () => {
it('should create audit log for backup creation', async () => { it('should create audit log for backup creation', async () => {
const adminId = 'admin-user-backup-123'; const adminId = '7c9e6679-7425-40de-944b-e07fc1f90ae7';
const backupId = 'backup-uuid-123'; const backupId = 'backup-uuid-123';
const entry = await service.info( const entry = await service.info(
'system', 'system',
@@ -215,7 +215,7 @@ describe('AuditLog Feature Integration', () => {
}); });
it('should create audit log for backup restore', async () => { it('should create audit log for backup restore', async () => {
const adminId = 'admin-user-backup-456'; const adminId = '7c9e6679-7425-40de-944b-e07fc1f90ae7';
const backupId = 'backup-uuid-456'; const backupId = 'backup-uuid-456';
const entry = await service.info( const entry = await service.info(
'system', 'system',
@@ -233,7 +233,7 @@ describe('AuditLog Feature Integration', () => {
}); });
it('should create error-level audit log for backup failure', async () => { it('should create error-level audit log for backup failure', async () => {
const adminId = 'admin-user-backup-789'; const adminId = '7c9e6679-7425-40de-944b-e07fc1f90ae7';
const backupId = 'backup-uuid-789'; const backupId = 'backup-uuid-789';
const entry = await service.error( const entry = await service.error(
'system', 'system',
@@ -253,7 +253,7 @@ describe('AuditLog Feature Integration', () => {
}); });
it('should create error-level audit log for restore failure', async () => { it('should create error-level audit log for restore failure', async () => {
const adminId = 'admin-user-restore-fail'; const adminId = '7c9e6679-7425-40de-944b-e07fc1f90ae7';
const backupId = 'backup-uuid-restore-fail'; const backupId = 'backup-uuid-restore-fail';
const entry = await service.error( const entry = await service.error(
'system', 'system',

View File

@@ -126,7 +126,7 @@ export class AuditLogRepository {
al.resource_type, al.resource_id, al.details, al.created_at, al.resource_type, al.resource_id, al.details, al.created_at,
up.email as user_email up.email as user_email
FROM audit_logs al FROM audit_logs al
LEFT JOIN user_profiles up ON al.user_id = up.auth0_sub LEFT JOIN user_profiles up ON al.user_id = up.id
${whereClause} ${whereClause}
ORDER BY al.created_at DESC ORDER BY al.created_at DESC
LIMIT $${nextParamIndex} OFFSET $${nextParamIndex + 1} LIMIT $${nextParamIndex} OFFSET $${nextParamIndex + 1}
@@ -170,7 +170,7 @@ export class AuditLogRepository {
al.resource_type, al.resource_id, al.details, al.created_at, al.resource_type, al.resource_id, al.details, al.created_at,
up.email as user_email up.email as user_email
FROM audit_logs al FROM audit_logs al
LEFT JOIN user_profiles up ON al.user_id = up.auth0_sub LEFT JOIN user_profiles up ON al.user_id = up.id
${whereClause} ${whereClause}
ORDER BY al.created_at DESC ORDER BY al.created_at DESC
LIMIT ${MAX_EXPORT_RECORDS} LIMIT ${MAX_EXPORT_RECORDS}

View File

@@ -19,6 +19,7 @@ jest.mock('../../../../core/plugins/auth.plugin', () => {
return { return {
default: fastifyPlugin(async function (fastify) { default: fastifyPlugin(async function (fastify) {
fastify.decorate('authenticate', async function (request, _reply) { fastify.decorate('authenticate', async function (request, _reply) {
// JWT sub is still auth0|xxx format
request.user = { sub: 'auth0|test-user-123' }; request.user = { sub: 'auth0|test-user-123' };
}); });
}, { name: 'auth-plugin' }), }, { name: 'auth-plugin' }),

View File

@@ -103,6 +103,8 @@ describe('AuthService', () => {
onboardingCompletedAt: null, onboardingCompletedAt: null,
deactivatedAt: null, deactivatedAt: null,
deactivatedBy: null, deactivatedBy: null,
deletionRequestedAt: null,
deletionScheduledFor: null,
createdAt: new Date(), createdAt: new Date(),
updatedAt: new Date(), updatedAt: new Date(),
}); });
@@ -116,6 +118,8 @@ describe('AuthService', () => {
onboardingCompletedAt: null, onboardingCompletedAt: null,
deactivatedAt: null, deactivatedAt: null,
deactivatedBy: null, deactivatedBy: null,
deletionRequestedAt: null,
deletionScheduledFor: null,
createdAt: new Date(), createdAt: new Date(),
updatedAt: new Date(), updatedAt: new Date(),
}); });
@@ -149,6 +153,8 @@ describe('AuthService', () => {
onboardingCompletedAt: null, onboardingCompletedAt: null,
deactivatedAt: null, deactivatedAt: null,
deactivatedBy: null, deactivatedBy: null,
deletionRequestedAt: null,
deletionScheduledFor: null,
createdAt: new Date(), createdAt: new Date(),
updatedAt: new Date(), updatedAt: new Date(),
}); });

View File

@@ -45,12 +45,12 @@ export class BackupController {
request: FastifyRequest<{ Body: CreateBackupBody }>, request: FastifyRequest<{ Body: CreateBackupBody }>,
reply: FastifyReply reply: FastifyReply
): Promise<void> { ): Promise<void> {
const adminSub = (request as any).userContext?.auth0Sub; const adminUserId = request.userContext?.userId;
const result = await this.backupService.createBackup({ const result = await this.backupService.createBackup({
name: request.body.name, name: request.body.name,
backupType: 'manual', backupType: 'manual',
createdBy: adminSub, createdBy: adminUserId,
includeDocuments: request.body.includeDocuments, includeDocuments: request.body.includeDocuments,
}); });
@@ -58,7 +58,7 @@ export class BackupController {
// Log backup creation to unified audit log // Log backup creation to unified audit log
await auditLogService.info( await auditLogService.info(
'system', 'system',
adminSub || null, adminUserId || null,
`Backup created: ${request.body.name || 'Manual backup'}`, `Backup created: ${request.body.name || 'Manual backup'}`,
'backup', 'backup',
result.backupId, result.backupId,
@@ -74,7 +74,7 @@ export class BackupController {
// Log backup failure // Log backup failure
await auditLogService.error( await auditLogService.error(
'system', 'system',
adminSub || null, adminUserId || null,
`Backup failed: ${request.body.name || 'Manual backup'}`, `Backup failed: ${request.body.name || 'Manual backup'}`,
'backup', 'backup',
result.backupId, result.backupId,
@@ -139,7 +139,7 @@ export class BackupController {
request: FastifyRequest, request: FastifyRequest,
reply: FastifyReply reply: FastifyReply
): Promise<void> { ): Promise<void> {
const adminSub = (request as any).userContext?.auth0Sub; const adminUserId = request.userContext?.userId;
// Handle multipart file upload // Handle multipart file upload
const data = await request.file(); const data = await request.file();
@@ -173,7 +173,7 @@ export class BackupController {
const backup = await this.backupService.importUploadedBackup( const backup = await this.backupService.importUploadedBackup(
tempPath, tempPath,
filename, filename,
adminSub adminUserId
); );
reply.status(201).send({ reply.status(201).send({
@@ -217,7 +217,7 @@ export class BackupController {
request: FastifyRequest<{ Params: BackupIdParam; Body: RestoreBody }>, request: FastifyRequest<{ Params: BackupIdParam; Body: RestoreBody }>,
reply: FastifyReply reply: FastifyReply
): Promise<void> { ): Promise<void> {
const adminSub = (request as any).userContext?.auth0Sub; const adminUserId = request.userContext?.userId;
try { try {
const result = await this.restoreService.executeRestore({ const result = await this.restoreService.executeRestore({
@@ -229,7 +229,7 @@ export class BackupController {
// Log successful restore to unified audit log // Log successful restore to unified audit log
await auditLogService.info( await auditLogService.info(
'system', 'system',
adminSub || null, adminUserId || null,
`Backup restored: ${request.params.id}`, `Backup restored: ${request.params.id}`,
'backup', 'backup',
request.params.id, request.params.id,
@@ -246,7 +246,7 @@ export class BackupController {
// Log restore failure // Log restore failure
await auditLogService.error( await auditLogService.error(
'system', 'system',
adminSub || null, adminUserId || null,
`Backup restore failed: ${request.params.id}`, `Backup restore failed: ${request.params.id}`,
'backup', 'backup',
request.params.id, request.params.id,

View File

@@ -73,7 +73,7 @@
}, },
"responseWithEfficiency": { "responseWithEfficiency": {
"id": "f47ac10b-58cc-4372-a567-0e02b2c3d479", "id": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
"userId": "auth0|user123", "userId": "550e8400-e29b-41d4-a716-446655440000",
"vehicleId": "550e8400-e29b-41d4-a716-446655440000", "vehicleId": "550e8400-e29b-41d4-a716-446655440000",
"dateTime": "2024-01-15T10:30:00Z", "dateTime": "2024-01-15T10:30:00Z",
"odometerReading": 52000, "odometerReading": 52000,

View File

@@ -99,7 +99,7 @@
}, },
"maintenanceScheduleResponse": { "maintenanceScheduleResponse": {
"id": "f47ac10b-58cc-4372-a567-0e02b2c3d479", "id": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
"userId": "auth0|user123", "userId": "550e8400-e29b-41d4-a716-446655440000",
"vehicleId": "550e8400-e29b-41d4-a716-446655440000", "vehicleId": "550e8400-e29b-41d4-a716-446655440000",
"type": "oil_change", "type": "oil_change",
"category": "routine_maintenance", "category": "routine_maintenance",

View File

@@ -33,7 +33,7 @@ export class OcrController {
request: FastifyRequest<{ Querystring: ExtractQuery }>, request: FastifyRequest<{ Querystring: ExtractQuery }>,
reply: FastifyReply reply: FastifyReply
) { ) {
const userId = (request as any).user?.sub as string; const userId = request.userContext?.userId as string;
const preprocess = request.query.preprocess !== false; const preprocess = request.query.preprocess !== false;
logger.info('OCR extract requested', { logger.info('OCR extract requested', {
@@ -140,7 +140,7 @@ export class OcrController {
request: FastifyRequest, request: FastifyRequest,
reply: FastifyReply reply: FastifyReply
) { ) {
const userId = (request as any).user?.sub as string; const userId = request.userContext?.userId as string;
logger.info('VIN extract requested', { logger.info('VIN extract requested', {
operation: 'ocr.controller.extractVin', operation: 'ocr.controller.extractVin',
@@ -240,7 +240,7 @@ export class OcrController {
request: FastifyRequest, request: FastifyRequest,
reply: FastifyReply reply: FastifyReply
) { ) {
const userId = (request as any).user?.sub as string; const userId = request.userContext?.userId as string;
logger.info('Receipt extract requested', { logger.info('Receipt extract requested', {
operation: 'ocr.controller.extractReceipt', operation: 'ocr.controller.extractReceipt',
@@ -352,7 +352,7 @@ export class OcrController {
request: FastifyRequest, request: FastifyRequest,
reply: FastifyReply reply: FastifyReply
) { ) {
const userId = (request as any).user?.sub as string; const userId = request.userContext?.userId as string;
logger.info('Maintenance receipt extract requested', { logger.info('Maintenance receipt extract requested', {
operation: 'ocr.controller.extractMaintenanceReceipt', operation: 'ocr.controller.extractMaintenanceReceipt',
@@ -460,7 +460,7 @@ export class OcrController {
request: FastifyRequest, request: FastifyRequest,
reply: FastifyReply reply: FastifyReply
) { ) {
const userId = (request as any).user?.sub as string; const userId = request.userContext?.userId as string;
logger.info('Manual extract requested', { logger.info('Manual extract requested', {
operation: 'ocr.controller.extractManual', operation: 'ocr.controller.extractManual',
@@ -584,7 +584,7 @@ export class OcrController {
request: FastifyRequest<{ Body: JobSubmitBody }>, request: FastifyRequest<{ Body: JobSubmitBody }>,
reply: FastifyReply reply: FastifyReply
) { ) {
const userId = (request as any).user?.sub as string; const userId = request.userContext?.userId as string;
logger.info('OCR job submit requested', { logger.info('OCR job submit requested', {
operation: 'ocr.controller.submitJob', operation: 'ocr.controller.submitJob',
@@ -691,7 +691,7 @@ export class OcrController {
request: FastifyRequest<{ Params: JobIdParams }>, request: FastifyRequest<{ Params: JobIdParams }>,
reply: FastifyReply reply: FastifyReply
) { ) {
const userId = (request as any).user?.sub as string; const userId = request.userContext?.userId as string;
const { jobId } = request.params; const { jobId } = request.params;
logger.debug('OCR job status requested', { logger.debug('OCR job status requested', {

View File

@@ -12,8 +12,8 @@ describe('Community Stations API Integration Tests', () => {
let app: FastifyInstance; let app: FastifyInstance;
let pool: Pool; let pool: Pool;
const testUserId = 'auth0|test-user-123'; const testUserId = '550e8400-e29b-41d4-a716-446655440000';
const testAdminId = 'auth0|test-admin-123'; const testAdminId = '7c9e6679-7425-40de-944b-e07fc1f90ae7';
const mockStationData = { const mockStationData = {
name: 'Test Gas Station', name: 'Test Gas Station',

View File

@@ -18,11 +18,12 @@ import {
export class UserProfileController { export class UserProfileController {
private userProfileService: UserProfileService; private userProfileService: UserProfileService;
private userProfileRepository: UserProfileRepository;
constructor() { constructor() {
const repository = new UserProfileRepository(pool); this.userProfileRepository = new UserProfileRepository(pool);
const adminRepository = new AdminRepository(pool); const adminRepository = new AdminRepository(pool);
this.userProfileService = new UserProfileService(repository); this.userProfileService = new UserProfileService(this.userProfileRepository);
this.userProfileService.setAdminRepository(adminRepository); this.userProfileService.setAdminRepository(adminRepository);
} }
@@ -31,27 +32,24 @@ export class UserProfileController {
*/ */
async getProfile(request: FastifyRequest, reply: FastifyReply) { async getProfile(request: FastifyRequest, reply: FastifyReply) {
try { try {
const auth0Sub = request.userContext?.userId; const userId = request.userContext?.userId;
if (!auth0Sub) { if (!userId) {
return reply.code(401).send({ return reply.code(401).send({
error: 'Unauthorized', error: 'Unauthorized',
message: 'User context missing', message: 'User context missing',
}); });
} }
// Get user data from Auth0 token // Get profile by UUID (auth plugin ensures profile exists during authentication)
const auth0User = { const profile = await this.userProfileRepository.getById(userId);
sub: auth0Sub,
email: (request as any).user?.email || request.userContext?.email || '',
name: (request as any).user?.name,
};
// Get or create profile if (!profile) {
const profile = await this.userProfileService.getOrCreateProfile( return reply.code(404).send({
auth0Sub, error: 'Not Found',
auth0User message: 'User profile not found',
); });
}
return reply.code(200).send(profile); return reply.code(200).send(profile);
} catch (error: any) { } catch (error: any) {
@@ -75,9 +73,9 @@ export class UserProfileController {
reply: FastifyReply reply: FastifyReply
) { ) {
try { try {
const auth0Sub = request.userContext?.userId; const userId = request.userContext?.userId;
if (!auth0Sub) { if (!userId) {
return reply.code(401).send({ return reply.code(401).send({
error: 'Unauthorized', error: 'Unauthorized',
message: 'User context missing', message: 'User context missing',
@@ -96,9 +94,9 @@ export class UserProfileController {
const updates = validation.data; const updates = validation.data;
// Update profile // Update profile by UUID
const profile = await this.userProfileService.updateProfile( const profile = await this.userProfileService.updateProfile(
auth0Sub, userId,
updates updates
); );
@@ -138,9 +136,9 @@ export class UserProfileController {
reply: FastifyReply reply: FastifyReply
) { ) {
try { try {
const auth0Sub = request.userContext?.userId; const userId = request.userContext?.userId;
if (!auth0Sub) { if (!userId) {
return reply.code(401).send({ return reply.code(401).send({
error: 'Unauthorized', error: 'Unauthorized',
message: 'User context missing', message: 'User context missing',
@@ -159,9 +157,9 @@ export class UserProfileController {
const { confirmationText } = validation.data; const { confirmationText } = validation.data;
// Request deletion (user is already authenticated via JWT) // Request deletion by UUID
const profile = await this.userProfileService.requestDeletion( const profile = await this.userProfileService.requestDeletion(
auth0Sub, userId,
confirmationText confirmationText
); );
@@ -210,17 +208,17 @@ export class UserProfileController {
*/ */
async cancelDeletion(request: FastifyRequest, reply: FastifyReply) { async cancelDeletion(request: FastifyRequest, reply: FastifyReply) {
try { try {
const auth0Sub = request.userContext?.userId; const userId = request.userContext?.userId;
if (!auth0Sub) { if (!userId) {
return reply.code(401).send({ return reply.code(401).send({
error: 'Unauthorized', error: 'Unauthorized',
message: 'User context missing', message: 'User context missing',
}); });
} }
// Cancel deletion // Cancel deletion by UUID
const profile = await this.userProfileService.cancelDeletion(auth0Sub); const profile = await this.userProfileService.cancelDeletion(userId);
return reply.code(200).send({ return reply.code(200).send({
message: 'Account deletion canceled successfully', message: 'Account deletion canceled successfully',
@@ -258,27 +256,24 @@ export class UserProfileController {
*/ */
async getDeletionStatus(request: FastifyRequest, reply: FastifyReply) { async getDeletionStatus(request: FastifyRequest, reply: FastifyReply) {
try { try {
const auth0Sub = request.userContext?.userId; const userId = request.userContext?.userId;
if (!auth0Sub) { if (!userId) {
return reply.code(401).send({ return reply.code(401).send({
error: 'Unauthorized', error: 'Unauthorized',
message: 'User context missing', message: 'User context missing',
}); });
} }
// Get user data from Auth0 token // Get profile by UUID (auth plugin ensures profile exists)
const auth0User = { const profile = await this.userProfileRepository.getById(userId);
sub: auth0Sub,
email: (request as any).user?.email || request.userContext?.email || '',
name: (request as any).user?.name,
};
// Get or create profile if (!profile) {
const profile = await this.userProfileService.getOrCreateProfile( return reply.code(404).send({
auth0Sub, error: 'Not Found',
auth0User message: 'User profile not found',
); });
}
const deletionStatus = this.userProfileService.getDeletionStatus(profile); const deletionStatus = this.userProfileService.getDeletionStatus(profile);

View File

@@ -44,6 +44,26 @@ export class UserProfileRepository {
} }
} }
async getById(id: string): Promise<UserProfile | null> {
const query = `
SELECT ${USER_PROFILE_COLUMNS}
FROM user_profiles
WHERE id = $1
LIMIT 1
`;
try {
const result = await this.pool.query(query, [id]);
if (result.rows.length === 0) {
return null;
}
return this.mapRowToUserProfile(result.rows[0]);
} catch (error) {
logger.error('Error fetching user profile by id', { error, id });
throw error;
}
}
async getByEmail(email: string): Promise<UserProfile | null> { async getByEmail(email: string): Promise<UserProfile | null> {
const query = ` const query = `
SELECT ${USER_PROFILE_COLUMNS} SELECT ${USER_PROFILE_COLUMNS}
@@ -94,7 +114,7 @@ export class UserProfileRepository {
} }
async update( async update(
auth0Sub: string, userId: string,
updates: { displayName?: string; notificationEmail?: string } updates: { displayName?: string; notificationEmail?: string }
): Promise<UserProfile> { ): Promise<UserProfile> {
const setClauses: string[] = []; const setClauses: string[] = [];
@@ -115,12 +135,12 @@ export class UserProfileRepository {
throw new Error('No fields to update'); throw new Error('No fields to update');
} }
values.push(auth0Sub); values.push(userId);
const query = ` const query = `
UPDATE user_profiles UPDATE user_profiles
SET ${setClauses.join(', ')} SET ${setClauses.join(', ')}
WHERE auth0_sub = $${paramIndex} WHERE id = $${paramIndex}
RETURNING ${USER_PROFILE_COLUMNS} RETURNING ${USER_PROFILE_COLUMNS}
`; `;
@@ -133,7 +153,7 @@ export class UserProfileRepository {
return this.mapRowToUserProfile(result.rows[0]); return this.mapRowToUserProfile(result.rows[0]);
} catch (error) { } catch (error) {
logger.error('Error updating user profile', { error, auth0Sub, updates }); logger.error('Error updating user profile', { error, userId, updates });
throw error; throw error;
} }
} }
@@ -174,7 +194,7 @@ export class UserProfileRepository {
private mapRowToUserWithAdminStatus(row: any): UserWithAdminStatus { private mapRowToUserWithAdminStatus(row: any): UserWithAdminStatus {
return { return {
...this.mapRowToUserProfile(row), ...this.mapRowToUserProfile(row),
isAdmin: !!row.admin_auth0_sub, isAdmin: !!row.admin_id,
adminRole: row.admin_role || null, adminRole: row.admin_role || null,
vehicleCount: parseInt(row.vehicle_count, 10) || 0, vehicleCount: parseInt(row.vehicle_count, 10) || 0,
}; };
@@ -242,14 +262,14 @@ export class UserProfileRepository {
up.id, up.auth0_sub, up.email, up.display_name, up.notification_email, up.id, up.auth0_sub, up.email, up.display_name, up.notification_email,
up.subscription_tier, up.email_verified, up.onboarding_completed_at, up.subscription_tier, up.email_verified, up.onboarding_completed_at,
up.deactivated_at, up.deactivated_by, up.created_at, up.updated_at, up.deactivated_at, up.deactivated_by, up.created_at, up.updated_at,
au.auth0_sub as admin_auth0_sub, au.id as admin_id,
au.role as admin_role, au.role as admin_role,
(SELECT COUNT(*) FROM vehicles v (SELECT COUNT(*) FROM vehicles v
WHERE v.user_id = up.auth0_sub WHERE v.user_id = up.id
AND v.is_active = true AND v.is_active = true
AND v.deleted_at IS NULL) as vehicle_count AND v.deleted_at IS NULL) as vehicle_count
FROM user_profiles up FROM user_profiles up
LEFT JOIN admin_users au ON up.auth0_sub = au.auth0_sub AND au.revoked_at IS NULL LEFT JOIN admin_users au ON au.user_profile_id = up.id AND au.revoked_at IS NULL
${whereClause} ${whereClause}
ORDER BY ${sortColumn} ${sortOrder === 'desc' ? 'DESC' : 'ASC'} NULLS LAST ORDER BY ${sortColumn} ${sortOrder === 'desc' ? 'DESC' : 'ASC'} NULLS LAST
LIMIT $${paramIndex} OFFSET $${paramIndex + 1} LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
@@ -274,32 +294,32 @@ export class UserProfileRepository {
/** /**
* Get single user with admin status * Get single user with admin status
*/ */
async getUserWithAdminStatus(auth0Sub: string): Promise<UserWithAdminStatus | null> { async getUserWithAdminStatus(userId: string): Promise<UserWithAdminStatus | null> {
const query = ` const query = `
SELECT SELECT
up.id, up.auth0_sub, up.email, up.display_name, up.notification_email, up.id, up.auth0_sub, up.email, up.display_name, up.notification_email,
up.subscription_tier, up.email_verified, up.onboarding_completed_at, up.subscription_tier, up.email_verified, up.onboarding_completed_at,
up.deactivated_at, up.deactivated_by, up.created_at, up.updated_at, up.deactivated_at, up.deactivated_by, up.created_at, up.updated_at,
au.auth0_sub as admin_auth0_sub, au.id as admin_id,
au.role as admin_role, au.role as admin_role,
(SELECT COUNT(*) FROM vehicles v (SELECT COUNT(*) FROM vehicles v
WHERE v.user_id = up.auth0_sub WHERE v.user_id = up.id
AND v.is_active = true AND v.is_active = true
AND v.deleted_at IS NULL) as vehicle_count AND v.deleted_at IS NULL) as vehicle_count
FROM user_profiles up FROM user_profiles up
LEFT JOIN admin_users au ON up.auth0_sub = au.auth0_sub AND au.revoked_at IS NULL LEFT JOIN admin_users au ON au.user_profile_id = up.id AND au.revoked_at IS NULL
WHERE up.auth0_sub = $1 WHERE up.id = $1
LIMIT 1 LIMIT 1
`; `;
try { try {
const result = await this.pool.query(query, [auth0Sub]); const result = await this.pool.query(query, [userId]);
if (result.rows.length === 0) { if (result.rows.length === 0) {
return null; return null;
} }
return this.mapRowToUserWithAdminStatus(result.rows[0]); return this.mapRowToUserWithAdminStatus(result.rows[0]);
} catch (error) { } catch (error) {
logger.error('Error fetching user with admin status', { error, auth0Sub }); logger.error('Error fetching user with admin status', { error, userId });
throw error; throw error;
} }
} }
@@ -308,24 +328,24 @@ export class UserProfileRepository {
* Update user subscription tier * Update user subscription tier
*/ */
async updateSubscriptionTier( async updateSubscriptionTier(
auth0Sub: string, userId: string,
tier: SubscriptionTier tier: SubscriptionTier
): Promise<UserProfile> { ): Promise<UserProfile> {
const query = ` const query = `
UPDATE user_profiles UPDATE user_profiles
SET subscription_tier = $1 SET subscription_tier = $1
WHERE auth0_sub = $2 WHERE id = $2
RETURNING ${USER_PROFILE_COLUMNS} RETURNING ${USER_PROFILE_COLUMNS}
`; `;
try { try {
const result = await this.pool.query(query, [tier, auth0Sub]); const result = await this.pool.query(query, [tier, userId]);
if (result.rows.length === 0) { if (result.rows.length === 0) {
throw new Error('User profile not found'); throw new Error('User profile not found');
} }
return this.mapRowToUserProfile(result.rows[0]); return this.mapRowToUserProfile(result.rows[0]);
} catch (error) { } catch (error) {
logger.error('Error updating subscription tier', { error, auth0Sub, tier }); logger.error('Error updating subscription tier', { error, userId, tier });
throw error; throw error;
} }
} }
@@ -333,22 +353,22 @@ export class UserProfileRepository {
/** /**
* Deactivate user (soft delete) * Deactivate user (soft delete)
*/ */
async deactivateUser(auth0Sub: string, deactivatedBy: string): Promise<UserProfile> { async deactivateUser(userId: string, deactivatedBy: string): Promise<UserProfile> {
const query = ` const query = `
UPDATE user_profiles UPDATE user_profiles
SET deactivated_at = NOW(), deactivated_by = $1 SET deactivated_at = NOW(), deactivated_by = $1
WHERE auth0_sub = $2 AND deactivated_at IS NULL WHERE id = $2 AND deactivated_at IS NULL
RETURNING ${USER_PROFILE_COLUMNS} RETURNING ${USER_PROFILE_COLUMNS}
`; `;
try { try {
const result = await this.pool.query(query, [deactivatedBy, auth0Sub]); const result = await this.pool.query(query, [deactivatedBy, userId]);
if (result.rows.length === 0) { if (result.rows.length === 0) {
throw new Error('User profile not found or already deactivated'); throw new Error('User profile not found or already deactivated');
} }
return this.mapRowToUserProfile(result.rows[0]); return this.mapRowToUserProfile(result.rows[0]);
} catch (error) { } catch (error) {
logger.error('Error deactivating user', { error, auth0Sub, deactivatedBy }); logger.error('Error deactivating user', { error, userId, deactivatedBy });
throw error; throw error;
} }
} }
@@ -356,22 +376,22 @@ export class UserProfileRepository {
/** /**
* Reactivate user * Reactivate user
*/ */
async reactivateUser(auth0Sub: string): Promise<UserProfile> { async reactivateUser(userId: string): Promise<UserProfile> {
const query = ` const query = `
UPDATE user_profiles UPDATE user_profiles
SET deactivated_at = NULL, deactivated_by = NULL SET deactivated_at = NULL, deactivated_by = NULL
WHERE auth0_sub = $1 AND deactivated_at IS NOT NULL WHERE id = $1 AND deactivated_at IS NOT NULL
RETURNING ${USER_PROFILE_COLUMNS} RETURNING ${USER_PROFILE_COLUMNS}
`; `;
try { try {
const result = await this.pool.query(query, [auth0Sub]); const result = await this.pool.query(query, [userId]);
if (result.rows.length === 0) { if (result.rows.length === 0) {
throw new Error('User profile not found or not deactivated'); throw new Error('User profile not found or not deactivated');
} }
return this.mapRowToUserProfile(result.rows[0]); return this.mapRowToUserProfile(result.rows[0]);
} catch (error) { } catch (error) {
logger.error('Error reactivating user', { error, auth0Sub }); logger.error('Error reactivating user', { error, userId });
throw error; throw error;
} }
} }
@@ -380,7 +400,7 @@ export class UserProfileRepository {
* Admin update of user profile (can update email and displayName) * Admin update of user profile (can update email and displayName)
*/ */
async adminUpdateProfile( async adminUpdateProfile(
auth0Sub: string, userId: string,
updates: { email?: string; displayName?: string } updates: { email?: string; displayName?: string }
): Promise<UserProfile> { ): Promise<UserProfile> {
const setClauses: string[] = []; const setClauses: string[] = [];
@@ -401,12 +421,12 @@ export class UserProfileRepository {
throw new Error('No fields to update'); throw new Error('No fields to update');
} }
values.push(auth0Sub); values.push(userId);
const query = ` const query = `
UPDATE user_profiles UPDATE user_profiles
SET ${setClauses.join(', ')}, updated_at = NOW() SET ${setClauses.join(', ')}, updated_at = NOW()
WHERE auth0_sub = $${paramIndex} WHERE id = $${paramIndex}
RETURNING ${USER_PROFILE_COLUMNS} RETURNING ${USER_PROFILE_COLUMNS}
`; `;
@@ -419,7 +439,7 @@ export class UserProfileRepository {
return this.mapRowToUserProfile(result.rows[0]); return this.mapRowToUserProfile(result.rows[0]);
} catch (error) { } catch (error) {
logger.error('Error admin updating user profile', { error, auth0Sub, updates }); logger.error('Error admin updating user profile', { error, userId, updates });
throw error; throw error;
} }
} }
@@ -427,22 +447,22 @@ export class UserProfileRepository {
/** /**
* Update email verification status * Update email verification status
*/ */
async updateEmailVerified(auth0Sub: string, emailVerified: boolean): Promise<UserProfile> { async updateEmailVerified(userId: string, emailVerified: boolean): Promise<UserProfile> {
const query = ` const query = `
UPDATE user_profiles UPDATE user_profiles
SET email_verified = $1, updated_at = NOW() SET email_verified = $1, updated_at = NOW()
WHERE auth0_sub = $2 WHERE id = $2
RETURNING ${USER_PROFILE_COLUMNS} RETURNING ${USER_PROFILE_COLUMNS}
`; `;
try { try {
const result = await this.pool.query(query, [emailVerified, auth0Sub]); const result = await this.pool.query(query, [emailVerified, userId]);
if (result.rows.length === 0) { if (result.rows.length === 0) {
throw new Error('User profile not found'); throw new Error('User profile not found');
} }
return this.mapRowToUserProfile(result.rows[0]); return this.mapRowToUserProfile(result.rows[0]);
} catch (error) { } catch (error) {
logger.error('Error updating email verified status', { error, auth0Sub, emailVerified }); logger.error('Error updating email verified status', { error, userId, emailVerified });
throw error; throw error;
} }
} }
@@ -450,19 +470,19 @@ export class UserProfileRepository {
/** /**
* Mark onboarding as complete * Mark onboarding as complete
*/ */
async markOnboardingComplete(auth0Sub: string): Promise<UserProfile> { async markOnboardingComplete(userId: string): Promise<UserProfile> {
const query = ` const query = `
UPDATE user_profiles UPDATE user_profiles
SET onboarding_completed_at = NOW(), updated_at = NOW() SET onboarding_completed_at = NOW(), updated_at = NOW()
WHERE auth0_sub = $1 AND onboarding_completed_at IS NULL WHERE id = $1 AND onboarding_completed_at IS NULL
RETURNING ${USER_PROFILE_COLUMNS} RETURNING ${USER_PROFILE_COLUMNS}
`; `;
try { try {
const result = await this.pool.query(query, [auth0Sub]); const result = await this.pool.query(query, [userId]);
if (result.rows.length === 0) { if (result.rows.length === 0) {
// Check if already completed or profile not found // Check if already completed or profile not found
const existing = await this.getByAuth0Sub(auth0Sub); const existing = await this.getById(userId);
if (existing && existing.onboardingCompletedAt) { if (existing && existing.onboardingCompletedAt) {
return existing; // Already completed, return as-is return existing; // Already completed, return as-is
} }
@@ -470,7 +490,7 @@ export class UserProfileRepository {
} }
return this.mapRowToUserProfile(result.rows[0]); return this.mapRowToUserProfile(result.rows[0]);
} catch (error) { } catch (error) {
logger.error('Error marking onboarding complete', { error, auth0Sub }); logger.error('Error marking onboarding complete', { error, userId });
throw error; throw error;
} }
} }
@@ -478,22 +498,22 @@ export class UserProfileRepository {
/** /**
* Update user email (used when fetching correct email from Auth0) * Update user email (used when fetching correct email from Auth0)
*/ */
async updateEmail(auth0Sub: string, email: string): Promise<UserProfile> { async updateEmail(userId: string, email: string): Promise<UserProfile> {
const query = ` const query = `
UPDATE user_profiles UPDATE user_profiles
SET email = $1, updated_at = NOW() SET email = $1, updated_at = NOW()
WHERE auth0_sub = $2 WHERE id = $2
RETURNING ${USER_PROFILE_COLUMNS} RETURNING ${USER_PROFILE_COLUMNS}
`; `;
try { try {
const result = await this.pool.query(query, [email, auth0Sub]); const result = await this.pool.query(query, [email, userId]);
if (result.rows.length === 0) { if (result.rows.length === 0) {
throw new Error('User profile not found'); throw new Error('User profile not found');
} }
return this.mapRowToUserProfile(result.rows[0]); return this.mapRowToUserProfile(result.rows[0]);
} catch (error) { } catch (error) {
logger.error('Error updating user email', { error, auth0Sub }); logger.error('Error updating user email', { error, userId });
throw error; throw error;
} }
} }
@@ -502,7 +522,7 @@ export class UserProfileRepository {
* Request account deletion (sets deletion timestamps and deactivates account) * Request account deletion (sets deletion timestamps and deactivates account)
* 30-day grace period before permanent deletion * 30-day grace period before permanent deletion
*/ */
async requestDeletion(auth0Sub: string): Promise<UserProfile> { async requestDeletion(userId: string): Promise<UserProfile> {
const query = ` const query = `
UPDATE user_profiles UPDATE user_profiles
SET SET
@@ -510,18 +530,18 @@ export class UserProfileRepository {
deletion_scheduled_for = NOW() + INTERVAL '30 days', deletion_scheduled_for = NOW() + INTERVAL '30 days',
deactivated_at = NOW(), deactivated_at = NOW(),
updated_at = NOW() updated_at = NOW()
WHERE auth0_sub = $1 AND deletion_requested_at IS NULL WHERE id = $1 AND deletion_requested_at IS NULL
RETURNING ${USER_PROFILE_COLUMNS} RETURNING ${USER_PROFILE_COLUMNS}
`; `;
try { try {
const result = await this.pool.query(query, [auth0Sub]); const result = await this.pool.query(query, [userId]);
if (result.rows.length === 0) { if (result.rows.length === 0) {
throw new Error('User profile not found or deletion already requested'); throw new Error('User profile not found or deletion already requested');
} }
return this.mapRowToUserProfile(result.rows[0]); return this.mapRowToUserProfile(result.rows[0]);
} catch (error) { } catch (error) {
logger.error('Error requesting account deletion', { error, auth0Sub }); logger.error('Error requesting account deletion', { error, userId });
throw error; throw error;
} }
} }
@@ -529,7 +549,7 @@ export class UserProfileRepository {
/** /**
* Cancel deletion request (clears deletion timestamps and reactivates account) * Cancel deletion request (clears deletion timestamps and reactivates account)
*/ */
async cancelDeletion(auth0Sub: string): Promise<UserProfile> { async cancelDeletion(userId: string): Promise<UserProfile> {
const query = ` const query = `
UPDATE user_profiles UPDATE user_profiles
SET SET
@@ -538,18 +558,18 @@ export class UserProfileRepository {
deactivated_at = NULL, deactivated_at = NULL,
deactivated_by = NULL, deactivated_by = NULL,
updated_at = NOW() updated_at = NOW()
WHERE auth0_sub = $1 AND deletion_requested_at IS NOT NULL WHERE id = $1 AND deletion_requested_at IS NOT NULL
RETURNING ${USER_PROFILE_COLUMNS} RETURNING ${USER_PROFILE_COLUMNS}
`; `;
try { try {
const result = await this.pool.query(query, [auth0Sub]); const result = await this.pool.query(query, [userId]);
if (result.rows.length === 0) { if (result.rows.length === 0) {
throw new Error('User profile not found or no deletion request pending'); throw new Error('User profile not found or no deletion request pending');
} }
return this.mapRowToUserProfile(result.rows[0]); return this.mapRowToUserProfile(result.rows[0]);
} catch (error) { } catch (error) {
logger.error('Error canceling account deletion', { error, auth0Sub }); logger.error('Error canceling account deletion', { error, userId });
throw error; throw error;
} }
} }
@@ -579,7 +599,7 @@ export class UserProfileRepository {
* Hard delete user and all associated data * Hard delete user and all associated data
* This is a permanent operation - use with caution * This is a permanent operation - use with caution
*/ */
async hardDeleteUser(auth0Sub: string): Promise<void> { async hardDeleteUser(userId: string): Promise<void> {
const client = await this.pool.connect(); const client = await this.pool.connect();
try { try {
@@ -590,51 +610,51 @@ export class UserProfileRepository {
`UPDATE community_stations `UPDATE community_stations
SET submitted_by = 'deleted-user' SET submitted_by = 'deleted-user'
WHERE submitted_by = $1`, WHERE submitted_by = $1`,
[auth0Sub] [userId]
); );
// 2. Delete notification logs // 2. Delete notification logs
await client.query( await client.query(
'DELETE FROM notification_logs WHERE user_id = $1', 'DELETE FROM notification_logs WHERE user_id = $1',
[auth0Sub] [userId]
); );
// 3. Delete user notifications // 3. Delete user notifications
await client.query( await client.query(
'DELETE FROM user_notifications WHERE user_id = $1', 'DELETE FROM user_notifications WHERE user_id = $1',
[auth0Sub] [userId]
); );
// 4. Delete saved stations // 4. Delete saved stations
await client.query( await client.query(
'DELETE FROM saved_stations WHERE user_id = $1', 'DELETE FROM saved_stations WHERE user_id = $1',
[auth0Sub] [userId]
); );
// 5. Delete vehicles (cascades to fuel_logs, maintenance_logs, maintenance_schedules, documents) // 5. Delete vehicles (cascades to fuel_logs, maintenance_logs, maintenance_schedules, documents)
await client.query( await client.query(
'DELETE FROM vehicles WHERE user_id = $1', 'DELETE FROM vehicles WHERE user_id = $1',
[auth0Sub] [userId]
); );
// 6. Delete user preferences // 6. Delete user preferences
await client.query( await client.query(
'DELETE FROM user_preferences WHERE user_id = $1', 'DELETE FROM user_preferences WHERE user_id = $1',
[auth0Sub] [userId]
); );
// 7. Delete user profile (final step) // 7. Delete user profile (final step)
await client.query( await client.query(
'DELETE FROM user_profiles WHERE auth0_sub = $1', 'DELETE FROM user_profiles WHERE id = $1',
[auth0Sub] [userId]
); );
await client.query('COMMIT'); await client.query('COMMIT');
logger.info('User hard deleted successfully', { auth0Sub }); logger.info('User hard deleted successfully', { userId });
} catch (error) { } catch (error) {
await client.query('ROLLBACK'); await client.query('ROLLBACK');
logger.error('Error hard deleting user', { error, auth0Sub }); logger.error('Error hard deleting user', { error, userId });
throw error; throw error;
} finally { } finally {
client.release(); client.release();
@@ -686,7 +706,7 @@ export class UserProfileRepository {
* Get vehicles for a user (admin view) * Get vehicles for a user (admin view)
* Returns only year, make, model for privacy * Returns only year, make, model for privacy
*/ */
async getUserVehiclesForAdmin(auth0Sub: string): Promise<Array<{ year: number; make: string; model: string }>> { async getUserVehiclesForAdmin(userId: string): Promise<Array<{ year: number; make: string; model: string }>> {
const query = ` const query = `
SELECT year, make, model SELECT year, make, model
FROM vehicles FROM vehicles
@@ -697,14 +717,14 @@ export class UserProfileRepository {
`; `;
try { try {
const result = await this.pool.query(query, [auth0Sub]); const result = await this.pool.query(query, [userId]);
return result.rows.map(row => ({ return result.rows.map(row => ({
year: row.year, year: row.year,
make: row.make, make: row.make,
model: row.model, model: row.model,
})); }));
} catch (error) { } catch (error) {
logger.error('Error getting user vehicles for admin', { error, auth0Sub }); logger.error('Error getting user vehicles for admin', { error, userId });
throw error; throw error;
} }
} }

View File

@@ -60,7 +60,7 @@ export class UserProfileService {
} }
/** /**
* Get user profile by Auth0 sub * Get user profile by Auth0 sub (used during auth flow)
*/ */
async getProfile(auth0Sub: string): Promise<UserProfile | null> { async getProfile(auth0Sub: string): Promise<UserProfile | null> {
try { try {
@@ -72,10 +72,10 @@ export class UserProfileService {
} }
/** /**
* Update user profile * Update user profile by UUID
*/ */
async updateProfile( async updateProfile(
auth0Sub: string, userId: string,
updates: UpdateProfileRequest updates: UpdateProfileRequest
): Promise<UserProfile> { ): Promise<UserProfile> {
try { try {
@@ -85,17 +85,17 @@ export class UserProfileService {
} }
// Perform the update // Perform the update
const profile = await this.repository.update(auth0Sub, updates); const profile = await this.repository.update(userId, updates);
logger.info('User profile updated', { logger.info('User profile updated', {
auth0Sub, userId,
profileId: profile.id, profileId: profile.id,
updatedFields: Object.keys(updates), updatedFields: Object.keys(updates),
}); });
return profile; return profile;
} catch (error) { } catch (error) {
logger.error('Error updating user profile', { error, auth0Sub, updates }); logger.error('Error updating user profile', { error, userId, updates });
throw error; throw error;
} }
} }
@@ -117,29 +117,29 @@ export class UserProfileService {
} }
/** /**
* Get user details with admin status (admin-only) * Get user details with admin status by UUID (admin-only)
*/ */
async getUserDetails(auth0Sub: string): Promise<UserWithAdminStatus | null> { async getUserDetails(userId: string): Promise<UserWithAdminStatus | null> {
try { try {
return await this.repository.getUserWithAdminStatus(auth0Sub); return await this.repository.getUserWithAdminStatus(userId);
} catch (error) { } catch (error) {
logger.error('Error getting user details', { error, auth0Sub }); logger.error('Error getting user details', { error, userId });
throw error; throw error;
} }
} }
/** /**
* Update user subscription tier (admin-only) * Update user subscription tier by UUID (admin-only)
* Logs the change to admin audit logs * Logs the change to admin audit logs
*/ */
async updateSubscriptionTier( async updateSubscriptionTier(
auth0Sub: string, userId: string,
tier: SubscriptionTier, tier: SubscriptionTier,
actorAuth0Sub: string actorUserId: string
): Promise<UserProfile> { ): Promise<UserProfile> {
try { try {
// Get current user to log the change // Get current user to log the change
const currentUser = await this.repository.getByAuth0Sub(auth0Sub); const currentUser = await this.repository.getById(userId);
if (!currentUser) { if (!currentUser) {
throw new Error('User not found'); throw new Error('User not found');
} }
@@ -147,14 +147,14 @@ export class UserProfileService {
const previousTier = currentUser.subscriptionTier; const previousTier = currentUser.subscriptionTier;
// Perform the update // Perform the update
const updatedProfile = await this.repository.updateSubscriptionTier(auth0Sub, tier); const updatedProfile = await this.repository.updateSubscriptionTier(userId, tier);
// Log to audit trail // Log to audit trail
if (this.adminRepository) { if (this.adminRepository) {
await this.adminRepository.logAuditAction( await this.adminRepository.logAuditAction(
actorAuth0Sub, actorUserId,
'UPDATE_TIER', 'UPDATE_TIER',
auth0Sub, userId,
'user_profile', 'user_profile',
updatedProfile.id, updatedProfile.id,
{ previousTier, newTier: tier } { previousTier, newTier: tier }
@@ -162,36 +162,36 @@ export class UserProfileService {
} }
logger.info('User subscription tier updated', { logger.info('User subscription tier updated', {
auth0Sub, userId,
previousTier, previousTier,
newTier: tier, newTier: tier,
actorAuth0Sub, actorUserId,
}); });
return updatedProfile; return updatedProfile;
} catch (error) { } catch (error) {
logger.error('Error updating subscription tier', { error, auth0Sub, tier, actorAuth0Sub }); logger.error('Error updating subscription tier', { error, userId, tier, actorUserId });
throw error; throw error;
} }
} }
/** /**
* Deactivate user account (admin-only soft delete) * Deactivate user account by UUID (admin-only soft delete)
* Prevents self-deactivation * Prevents self-deactivation
*/ */
async deactivateUser( async deactivateUser(
auth0Sub: string, userId: string,
actorAuth0Sub: string, actorUserId: string,
reason?: string reason?: string
): Promise<UserProfile> { ): Promise<UserProfile> {
try { try {
// Prevent self-deactivation // Prevent self-deactivation
if (auth0Sub === actorAuth0Sub) { if (userId === actorUserId) {
throw new Error('Cannot deactivate your own account'); throw new Error('Cannot deactivate your own account');
} }
// Verify user exists and is not already deactivated // Verify user exists and is not already deactivated
const currentUser = await this.repository.getByAuth0Sub(auth0Sub); const currentUser = await this.repository.getById(userId);
if (!currentUser) { if (!currentUser) {
throw new Error('User not found'); throw new Error('User not found');
} }
@@ -200,14 +200,14 @@ export class UserProfileService {
} }
// Perform the deactivation // Perform the deactivation
const deactivatedProfile = await this.repository.deactivateUser(auth0Sub, actorAuth0Sub); const deactivatedProfile = await this.repository.deactivateUser(userId, actorUserId);
// Log to audit trail // Log to audit trail
if (this.adminRepository) { if (this.adminRepository) {
await this.adminRepository.logAuditAction( await this.adminRepository.logAuditAction(
actorAuth0Sub, actorUserId,
'DEACTIVATE_USER', 'DEACTIVATE_USER',
auth0Sub, userId,
'user_profile', 'user_profile',
deactivatedProfile.id, deactivatedProfile.id,
{ reason: reason || 'No reason provided' } { reason: reason || 'No reason provided' }
@@ -215,28 +215,28 @@ export class UserProfileService {
} }
logger.info('User deactivated', { logger.info('User deactivated', {
auth0Sub, userId,
actorAuth0Sub, actorUserId,
reason, reason,
}); });
return deactivatedProfile; return deactivatedProfile;
} catch (error) { } catch (error) {
logger.error('Error deactivating user', { error, auth0Sub, actorAuth0Sub }); logger.error('Error deactivating user', { error, userId, actorUserId });
throw error; throw error;
} }
} }
/** /**
* Reactivate a deactivated user account (admin-only) * Reactivate a deactivated user account by UUID (admin-only)
*/ */
async reactivateUser( async reactivateUser(
auth0Sub: string, userId: string,
actorAuth0Sub: string actorUserId: string
): Promise<UserProfile> { ): Promise<UserProfile> {
try { try {
// Verify user exists and is deactivated // Verify user exists and is deactivated
const currentUser = await this.repository.getByAuth0Sub(auth0Sub); const currentUser = await this.repository.getById(userId);
if (!currentUser) { if (!currentUser) {
throw new Error('User not found'); throw new Error('User not found');
} }
@@ -245,14 +245,14 @@ export class UserProfileService {
} }
// Perform the reactivation // Perform the reactivation
const reactivatedProfile = await this.repository.reactivateUser(auth0Sub); const reactivatedProfile = await this.repository.reactivateUser(userId);
// Log to audit trail // Log to audit trail
if (this.adminRepository) { if (this.adminRepository) {
await this.adminRepository.logAuditAction( await this.adminRepository.logAuditAction(
actorAuth0Sub, actorUserId,
'REACTIVATE_USER', 'REACTIVATE_USER',
auth0Sub, userId,
'user_profile', 'user_profile',
reactivatedProfile.id, reactivatedProfile.id,
{ previouslyDeactivatedBy: currentUser.deactivatedBy } { previouslyDeactivatedBy: currentUser.deactivatedBy }
@@ -260,29 +260,29 @@ export class UserProfileService {
} }
logger.info('User reactivated', { logger.info('User reactivated', {
auth0Sub, userId,
actorAuth0Sub, actorUserId,
}); });
return reactivatedProfile; return reactivatedProfile;
} catch (error) { } catch (error) {
logger.error('Error reactivating user', { error, auth0Sub, actorAuth0Sub }); logger.error('Error reactivating user', { error, userId, actorUserId });
throw error; throw error;
} }
} }
/** /**
* Admin update of user profile (email, displayName) * Admin update of user profile by UUID (email, displayName)
* Logs the change to admin audit logs * Logs the change to admin audit logs
*/ */
async adminUpdateProfile( async adminUpdateProfile(
auth0Sub: string, userId: string,
updates: { email?: string; displayName?: string }, updates: { email?: string; displayName?: string },
actorAuth0Sub: string actorUserId: string
): Promise<UserProfile> { ): Promise<UserProfile> {
try { try {
// Get current user to log the change // Get current user to log the change
const currentUser = await this.repository.getByAuth0Sub(auth0Sub); const currentUser = await this.repository.getById(userId);
if (!currentUser) { if (!currentUser) {
throw new Error('User not found'); throw new Error('User not found');
} }
@@ -293,14 +293,14 @@ export class UserProfileService {
}; };
// Perform the update // Perform the update
const updatedProfile = await this.repository.adminUpdateProfile(auth0Sub, updates); const updatedProfile = await this.repository.adminUpdateProfile(userId, updates);
// Log to audit trail // Log to audit trail
if (this.adminRepository) { if (this.adminRepository) {
await this.adminRepository.logAuditAction( await this.adminRepository.logAuditAction(
actorAuth0Sub, actorUserId,
'UPDATE_PROFILE', 'UPDATE_PROFILE',
auth0Sub, userId,
'user_profile', 'user_profile',
updatedProfile.id, updatedProfile.id,
{ {
@@ -311,14 +311,14 @@ export class UserProfileService {
} }
logger.info('User profile updated by admin', { logger.info('User profile updated by admin', {
auth0Sub, userId,
updatedFields: Object.keys(updates), updatedFields: Object.keys(updates),
actorAuth0Sub, actorUserId,
}); });
return updatedProfile; return updatedProfile;
} catch (error) { } catch (error) {
logger.error('Error admin updating user profile', { error, auth0Sub, updates, actorAuth0Sub }); logger.error('Error admin updating user profile', { error, userId, updates, actorUserId });
throw error; throw error;
} }
} }
@@ -328,12 +328,12 @@ export class UserProfileService {
// ============================================ // ============================================
/** /**
* Request account deletion * Request account deletion by UUID
* Sets 30-day grace period before permanent deletion * Sets 30-day grace period before permanent deletion
* Note: User is already authenticated via JWT, confirmation text is sufficient * Note: User is already authenticated via JWT, confirmation text is sufficient
*/ */
async requestDeletion( async requestDeletion(
auth0Sub: string, userId: string,
confirmationText: string confirmationText: string
): Promise<UserProfile> { ): Promise<UserProfile> {
try { try {
@@ -343,7 +343,7 @@ export class UserProfileService {
} }
// Get user profile // Get user profile
const profile = await this.repository.getByAuth0Sub(auth0Sub); const profile = await this.repository.getById(userId);
if (!profile) { if (!profile) {
throw new Error('User not found'); throw new Error('User not found');
} }
@@ -354,14 +354,14 @@ export class UserProfileService {
} }
// Request deletion // Request deletion
const updatedProfile = await this.repository.requestDeletion(auth0Sub); const updatedProfile = await this.repository.requestDeletion(userId);
// Log to audit trail // Log to audit trail
if (this.adminRepository) { if (this.adminRepository) {
await this.adminRepository.logAuditAction( await this.adminRepository.logAuditAction(
auth0Sub, userId,
'REQUEST_DELETION', 'REQUEST_DELETION',
auth0Sub, userId,
'user_profile', 'user_profile',
updatedProfile.id, updatedProfile.id,
{ {
@@ -371,42 +371,42 @@ export class UserProfileService {
} }
logger.info('Account deletion requested', { logger.info('Account deletion requested', {
auth0Sub, userId,
deletionScheduledFor: updatedProfile.deletionScheduledFor, deletionScheduledFor: updatedProfile.deletionScheduledFor,
}); });
return updatedProfile; return updatedProfile;
} catch (error) { } catch (error) {
logger.error('Error requesting account deletion', { error, auth0Sub }); logger.error('Error requesting account deletion', { error, userId });
throw error; throw error;
} }
} }
/** /**
* Cancel pending deletion request * Cancel pending deletion request by UUID
*/ */
async cancelDeletion(auth0Sub: string): Promise<UserProfile> { async cancelDeletion(userId: string): Promise<UserProfile> {
try { try {
// Cancel deletion // Cancel deletion
const updatedProfile = await this.repository.cancelDeletion(auth0Sub); const updatedProfile = await this.repository.cancelDeletion(userId);
// Log to audit trail // Log to audit trail
if (this.adminRepository) { if (this.adminRepository) {
await this.adminRepository.logAuditAction( await this.adminRepository.logAuditAction(
auth0Sub, userId,
'CANCEL_DELETION', 'CANCEL_DELETION',
auth0Sub, userId,
'user_profile', 'user_profile',
updatedProfile.id, updatedProfile.id,
{} {}
); );
} }
logger.info('Account deletion canceled', { auth0Sub }); logger.info('Account deletion canceled', { userId });
return updatedProfile; return updatedProfile;
} catch (error) { } catch (error) {
logger.error('Error canceling account deletion', { error, auth0Sub }); logger.error('Error canceling account deletion', { error, userId });
throw error; throw error;
} }
} }
@@ -438,22 +438,22 @@ export class UserProfileService {
} }
/** /**
* Admin hard delete user (permanent deletion) * Admin hard delete user by UUID (permanent deletion)
* Prevents self-delete * Prevents self-delete
*/ */
async adminHardDeleteUser( async adminHardDeleteUser(
auth0Sub: string, userId: string,
actorAuth0Sub: string, actorUserId: string,
reason?: string reason?: string
): Promise<void> { ): Promise<void> {
try { try {
// Prevent self-delete // Prevent self-delete
if (auth0Sub === actorAuth0Sub) { if (userId === actorUserId) {
throw new Error('Cannot delete your own account'); throw new Error('Cannot delete your own account');
} }
// Get user profile before deletion for audit log // Get user profile before deletion for audit log
const profile = await this.repository.getByAuth0Sub(auth0Sub); const profile = await this.repository.getById(userId);
if (!profile) { if (!profile) {
throw new Error('User not found'); throw new Error('User not found');
} }
@@ -461,9 +461,9 @@ export class UserProfileService {
// Log to audit trail before deletion // Log to audit trail before deletion
if (this.adminRepository) { if (this.adminRepository) {
await this.adminRepository.logAuditAction( await this.adminRepository.logAuditAction(
actorAuth0Sub, actorUserId,
'HARD_DELETE_USER', 'HARD_DELETE_USER',
auth0Sub, userId,
'user_profile', 'user_profile',
profile.id, profile.id,
{ {
@@ -475,18 +475,20 @@ export class UserProfileService {
} }
// Hard delete from database // Hard delete from database
await this.repository.hardDeleteUser(auth0Sub); await this.repository.hardDeleteUser(userId);
// Delete from Auth0 // Delete from Auth0 (using auth0Sub for Auth0 API)
await auth0ManagementClient.deleteUser(auth0Sub); if (profile.auth0Sub) {
await auth0ManagementClient.deleteUser(profile.auth0Sub);
}
logger.info('User hard deleted by admin', { logger.info('User hard deleted by admin', {
auth0Sub, userId,
actorAuth0Sub, actorUserId,
reason, reason,
}); });
} catch (error) { } catch (error) {
logger.error('Error hard deleting user', { error, auth0Sub, actorAuth0Sub }); logger.error('Error hard deleting user', { error, userId, actorUserId });
throw error; throw error;
} }
} }

View File

@@ -48,7 +48,8 @@ describe('AdminUsersPage', () => {
mockUseAdminAccess.mockReturnValue({ mockUseAdminAccess.mockReturnValue({
isAdmin: true, isAdmin: true,
adminRecord: { adminRecord: {
auth0Sub: 'auth0|123', id: 'admin-uuid-123',
userProfileId: 'user-uuid-123',
email: 'admin@example.com', email: 'admin@example.com',
role: 'admin', role: 'admin',
createdAt: '2024-01-01', createdAt: '2024-01-01',

View File

@@ -55,7 +55,8 @@ describe('useAdminAccess', () => {
mockAdminApi.verifyAccess.mockResolvedValue({ mockAdminApi.verifyAccess.mockResolvedValue({
isAdmin: true, isAdmin: true,
adminRecord: { adminRecord: {
auth0Sub: 'auth0|123', id: 'admin-uuid-123',
userProfileId: 'user-uuid-123',
email: 'admin@example.com', email: 'admin@example.com',
role: 'admin', role: 'admin',
createdAt: '2024-01-01', createdAt: '2024-01-01',

View File

@@ -42,7 +42,8 @@ describe('Admin user management hooks', () => {
it('should fetch admin users', async () => { it('should fetch admin users', async () => {
const mockAdmins = [ const mockAdmins = [
{ {
auth0Sub: 'auth0|123', id: 'admin-uuid-123',
userProfileId: 'user-uuid-123',
email: 'admin1@example.com', email: 'admin1@example.com',
role: 'admin', role: 'admin',
createdAt: '2024-01-01', createdAt: '2024-01-01',
@@ -68,11 +69,12 @@ describe('Admin user management hooks', () => {
describe('useCreateAdmin', () => { describe('useCreateAdmin', () => {
it('should create admin and show success toast', async () => { it('should create admin and show success toast', async () => {
const newAdmin = { const newAdmin = {
auth0Sub: 'auth0|456', id: 'admin-uuid-456',
userProfileId: 'user-uuid-456',
email: 'newadmin@example.com', email: 'newadmin@example.com',
role: 'admin', role: 'admin',
createdAt: '2024-01-01', createdAt: '2024-01-01',
createdBy: 'auth0|123', createdBy: 'admin-uuid-123',
revokedAt: null, revokedAt: null,
updatedAt: '2024-01-01', updatedAt: '2024-01-01',
}; };
@@ -131,11 +133,11 @@ describe('Admin user management hooks', () => {
wrapper: createWrapper(), wrapper: createWrapper(),
}); });
result.current.mutate('auth0|123'); result.current.mutate('admin-uuid-123');
await waitFor(() => expect(result.current.isSuccess).toBe(true)); await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(mockAdminApi.revokeAdmin).toHaveBeenCalledWith('auth0|123'); expect(mockAdminApi.revokeAdmin).toHaveBeenCalledWith('admin-uuid-123');
expect(toast.success).toHaveBeenCalledWith('Admin revoked successfully'); expect(toast.success).toHaveBeenCalledWith('Admin revoked successfully');
}); });
}); });
@@ -148,11 +150,11 @@ describe('Admin user management hooks', () => {
wrapper: createWrapper(), wrapper: createWrapper(),
}); });
result.current.mutate('auth0|123'); result.current.mutate('admin-uuid-123');
await waitFor(() => expect(result.current.isSuccess).toBe(true)); await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(mockAdminApi.reinstateAdmin).toHaveBeenCalledWith('auth0|123'); expect(mockAdminApi.reinstateAdmin).toHaveBeenCalledWith('admin-uuid-123');
expect(toast.success).toHaveBeenCalledWith('Admin reinstated successfully'); expect(toast.success).toHaveBeenCalledWith('Admin reinstated successfully');
}); });
}); });

View File

@@ -101,12 +101,12 @@ export const adminApi = {
return response.data; return response.data;
}, },
revokeAdmin: async (auth0Sub: string): Promise<void> => { revokeAdmin: async (id: string): Promise<void> => {
await apiClient.patch(`/admin/admins/${auth0Sub}/revoke`); await apiClient.patch(`/admin/admins/${id}/revoke`);
}, },
reinstateAdmin: async (auth0Sub: string): Promise<void> => { reinstateAdmin: async (id: string): Promise<void> => {
await apiClient.patch(`/admin/admins/${auth0Sub}/reinstate`); await apiClient.patch(`/admin/admins/${id}/reinstate`);
}, },
// Audit logs // Audit logs
@@ -328,62 +328,62 @@ export const adminApi = {
return response.data; return response.data;
}, },
get: async (auth0Sub: string): Promise<ManagedUser> => { get: async (userId: string): Promise<ManagedUser> => {
const response = await apiClient.get<ManagedUser>( const response = await apiClient.get<ManagedUser>(
`/admin/users/${encodeURIComponent(auth0Sub)}` `/admin/users/${userId}`
); );
return response.data; return response.data;
}, },
getVehicles: async (auth0Sub: string): Promise<AdminUserVehiclesResponse> => { getVehicles: async (userId: string): Promise<AdminUserVehiclesResponse> => {
const response = await apiClient.get<AdminUserVehiclesResponse>( const response = await apiClient.get<AdminUserVehiclesResponse>(
`/admin/users/${encodeURIComponent(auth0Sub)}/vehicles` `/admin/users/${userId}/vehicles`
); );
return response.data; return response.data;
}, },
updateTier: async (auth0Sub: string, data: UpdateUserTierRequest): Promise<ManagedUser> => { updateTier: async (userId: string, data: UpdateUserTierRequest): Promise<ManagedUser> => {
const response = await apiClient.patch<ManagedUser>( const response = await apiClient.patch<ManagedUser>(
`/admin/users/${encodeURIComponent(auth0Sub)}/tier`, `/admin/users/${userId}/tier`,
data data
); );
return response.data; return response.data;
}, },
deactivate: async (auth0Sub: string, data?: DeactivateUserRequest): Promise<ManagedUser> => { deactivate: async (userId: string, data?: DeactivateUserRequest): Promise<ManagedUser> => {
const response = await apiClient.patch<ManagedUser>( const response = await apiClient.patch<ManagedUser>(
`/admin/users/${encodeURIComponent(auth0Sub)}/deactivate`, `/admin/users/${userId}/deactivate`,
data || {} data || {}
); );
return response.data; return response.data;
}, },
reactivate: async (auth0Sub: string): Promise<ManagedUser> => { reactivate: async (userId: string): Promise<ManagedUser> => {
const response = await apiClient.patch<ManagedUser>( const response = await apiClient.patch<ManagedUser>(
`/admin/users/${encodeURIComponent(auth0Sub)}/reactivate` `/admin/users/${userId}/reactivate`
); );
return response.data; return response.data;
}, },
updateProfile: async (auth0Sub: string, data: UpdateUserProfileRequest): Promise<ManagedUser> => { updateProfile: async (userId: string, data: UpdateUserProfileRequest): Promise<ManagedUser> => {
const response = await apiClient.patch<ManagedUser>( const response = await apiClient.patch<ManagedUser>(
`/admin/users/${encodeURIComponent(auth0Sub)}/profile`, `/admin/users/${userId}/profile`,
data data
); );
return response.data; return response.data;
}, },
promoteToAdmin: async (auth0Sub: string, data?: PromoteToAdminRequest): Promise<AdminUser> => { promoteToAdmin: async (userId: string, data?: PromoteToAdminRequest): Promise<AdminUser> => {
const response = await apiClient.patch<AdminUser>( const response = await apiClient.patch<AdminUser>(
`/admin/users/${encodeURIComponent(auth0Sub)}/promote`, `/admin/users/${userId}/promote`,
data || {} data || {}
); );
return response.data; return response.data;
}, },
hardDelete: async (auth0Sub: string, reason?: string): Promise<{ message: string }> => { hardDelete: async (userId: string, reason?: string): Promise<{ message: string }> => {
const response = await apiClient.delete<{ message: string }>( const response = await apiClient.delete<{ message: string }>(
`/admin/users/${encodeURIComponent(auth0Sub)}`, `/admin/users/${userId}`,
{ params: reason ? { reason } : undefined } { params: reason ? { reason } : undefined }
); );
return response.data; return response.data;

View File

@@ -51,7 +51,7 @@ export const useRevokeAdmin = () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: (auth0Sub: string) => adminApi.revokeAdmin(auth0Sub), mutationFn: (id: string) => adminApi.revokeAdmin(id),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admins'] }); queryClient.invalidateQueries({ queryKey: ['admins'] });
toast.success('Admin revoked successfully'); toast.success('Admin revoked successfully');
@@ -66,7 +66,7 @@ export const useReinstateAdmin = () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: (auth0Sub: string) => adminApi.reinstateAdmin(auth0Sub), mutationFn: (id: string) => adminApi.reinstateAdmin(id),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admins'] }); queryClient.invalidateQueries({ queryKey: ['admins'] });
toast.success('Admin reinstated successfully'); toast.success('Admin reinstated successfully');

View File

@@ -29,8 +29,8 @@ interface ApiError {
export const userQueryKeys = { export const userQueryKeys = {
all: ['admin-users'] as const, all: ['admin-users'] as const,
list: (params: ListUsersParams) => [...userQueryKeys.all, 'list', params] as const, list: (params: ListUsersParams) => [...userQueryKeys.all, 'list', params] as const,
detail: (auth0Sub: string) => [...userQueryKeys.all, 'detail', auth0Sub] as const, detail: (userId: string) => [...userQueryKeys.all, 'detail', userId] as const,
vehicles: (auth0Sub: string) => [...userQueryKeys.all, 'vehicles', auth0Sub] as const, vehicles: (userId: string) => [...userQueryKeys.all, 'vehicles', userId] as const,
}; };
// Query keys for admin stats // Query keys for admin stats
@@ -58,13 +58,13 @@ export const useUsers = (params: ListUsersParams = {}) => {
/** /**
* Hook to get a single user's details * Hook to get a single user's details
*/ */
export const useUser = (auth0Sub: string) => { export const useUser = (userId: string) => {
const { isAuthenticated, isLoading } = useAuth0(); const { isAuthenticated, isLoading } = useAuth0();
return useQuery({ return useQuery({
queryKey: userQueryKeys.detail(auth0Sub), queryKey: userQueryKeys.detail(userId),
queryFn: () => adminApi.users.get(auth0Sub), queryFn: () => adminApi.users.get(userId),
enabled: isAuthenticated && !isLoading && !!auth0Sub, enabled: isAuthenticated && !isLoading && !!userId,
staleTime: 2 * 60 * 1000, staleTime: 2 * 60 * 1000,
gcTime: 5 * 60 * 1000, gcTime: 5 * 60 * 1000,
retry: 1, retry: 1,
@@ -78,8 +78,8 @@ export const useUpdateUserTier = () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: ({ auth0Sub, data }: { auth0Sub: string; data: UpdateUserTierRequest }) => mutationFn: ({ userId, data }: { userId: string; data: UpdateUserTierRequest }) =>
adminApi.users.updateTier(auth0Sub, data), adminApi.users.updateTier(userId, data),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: userQueryKeys.all }); queryClient.invalidateQueries({ queryKey: userQueryKeys.all });
toast.success('Subscription tier updated'); toast.success('Subscription tier updated');
@@ -101,8 +101,8 @@ export const useDeactivateUser = () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: ({ auth0Sub, data }: { auth0Sub: string; data?: DeactivateUserRequest }) => mutationFn: ({ userId, data }: { userId: string; data?: DeactivateUserRequest }) =>
adminApi.users.deactivate(auth0Sub, data), adminApi.users.deactivate(userId, data),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: userQueryKeys.all }); queryClient.invalidateQueries({ queryKey: userQueryKeys.all });
toast.success('User deactivated'); toast.success('User deactivated');
@@ -124,7 +124,7 @@ export const useReactivateUser = () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: (auth0Sub: string) => adminApi.users.reactivate(auth0Sub), mutationFn: (userId: string) => adminApi.users.reactivate(userId),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: userQueryKeys.all }); queryClient.invalidateQueries({ queryKey: userQueryKeys.all });
toast.success('User reactivated'); toast.success('User reactivated');
@@ -146,8 +146,8 @@ export const useUpdateUserProfile = () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: ({ auth0Sub, data }: { auth0Sub: string; data: UpdateUserProfileRequest }) => mutationFn: ({ userId, data }: { userId: string; data: UpdateUserProfileRequest }) =>
adminApi.users.updateProfile(auth0Sub, data), adminApi.users.updateProfile(userId, data),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: userQueryKeys.all }); queryClient.invalidateQueries({ queryKey: userQueryKeys.all });
toast.success('User profile updated'); toast.success('User profile updated');
@@ -169,8 +169,8 @@ export const usePromoteToAdmin = () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: ({ auth0Sub, data }: { auth0Sub: string; data?: PromoteToAdminRequest }) => mutationFn: ({ userId, data }: { userId: string; data?: PromoteToAdminRequest }) =>
adminApi.users.promoteToAdmin(auth0Sub, data), adminApi.users.promoteToAdmin(userId, data),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: userQueryKeys.all }); queryClient.invalidateQueries({ queryKey: userQueryKeys.all });
toast.success('User promoted to admin'); toast.success('User promoted to admin');
@@ -192,8 +192,8 @@ export const useHardDeleteUser = () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: ({ auth0Sub, reason }: { auth0Sub: string; reason?: string }) => mutationFn: ({ userId, reason }: { userId: string; reason?: string }) =>
adminApi.users.hardDelete(auth0Sub, reason), adminApi.users.hardDelete(userId, reason),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: userQueryKeys.all }); queryClient.invalidateQueries({ queryKey: userQueryKeys.all });
toast.success('User permanently deleted'); toast.success('User permanently deleted');
@@ -228,13 +228,13 @@ export const useAdminStats = () => {
/** /**
* Hook to get a user's vehicles (admin view - year, make, model only) * Hook to get a user's vehicles (admin view - year, make, model only)
*/ */
export const useUserVehicles = (auth0Sub: string) => { export const useUserVehicles = (userId: string) => {
const { isAuthenticated, isLoading } = useAuth0(); const { isAuthenticated, isLoading } = useAuth0();
return useQuery({ return useQuery({
queryKey: userQueryKeys.vehicles(auth0Sub), queryKey: userQueryKeys.vehicles(userId),
queryFn: () => adminApi.users.getVehicles(auth0Sub), queryFn: () => adminApi.users.getVehicles(userId),
enabled: isAuthenticated && !isLoading && !!auth0Sub, enabled: isAuthenticated && !isLoading && !!userId,
staleTime: 2 * 60 * 1000, staleTime: 2 * 60 * 1000,
gcTime: 5 * 60 * 1000, gcTime: 5 * 60 * 1000,
retry: 1, retry: 1,

View File

@@ -104,8 +104,8 @@ const VehicleCountBadge: React.FC<{ count: number; onClick?: () => void }> = ({
); );
// Expandable vehicle list component // Expandable vehicle list component
const UserVehiclesList: React.FC<{ auth0Sub: string; isOpen: boolean }> = ({ auth0Sub, isOpen }) => { const UserVehiclesList: React.FC<{ userId: string; isOpen: boolean }> = ({ userId, isOpen }) => {
const { data, isLoading, error } = useUserVehicles(auth0Sub); const { data, isLoading, error } = useUserVehicles(userId);
if (!isOpen) return null; if (!isOpen) return null;
@@ -215,7 +215,7 @@ export const AdminUsersMobileScreen: React.FC = () => {
(newTier: SubscriptionTier) => { (newTier: SubscriptionTier) => {
if (selectedUser) { if (selectedUser) {
updateTierMutation.mutate( updateTierMutation.mutate(
{ auth0Sub: selectedUser.auth0Sub, data: { subscriptionTier: newTier } }, { userId: selectedUser.id, data: { subscriptionTier: newTier } },
{ {
onSuccess: () => { onSuccess: () => {
setShowTierPicker(false); setShowTierPicker(false);
@@ -232,7 +232,7 @@ export const AdminUsersMobileScreen: React.FC = () => {
const handleDeactivate = useCallback(() => { const handleDeactivate = useCallback(() => {
if (selectedUser) { if (selectedUser) {
deactivateMutation.mutate( deactivateMutation.mutate(
{ auth0Sub: selectedUser.auth0Sub, data: { reason: deactivateReason || undefined } }, { userId: selectedUser.id, data: { reason: deactivateReason || undefined } },
{ {
onSuccess: () => { onSuccess: () => {
setShowDeactivateConfirm(false); setShowDeactivateConfirm(false);
@@ -247,7 +247,7 @@ export const AdminUsersMobileScreen: React.FC = () => {
const handleReactivate = useCallback(() => { const handleReactivate = useCallback(() => {
if (selectedUser) { if (selectedUser) {
reactivateMutation.mutate(selectedUser.auth0Sub, { reactivateMutation.mutate(selectedUser.id, {
onSuccess: () => { onSuccess: () => {
setShowUserActions(false); setShowUserActions(false);
setSelectedUser(null); setSelectedUser(null);
@@ -276,7 +276,7 @@ export const AdminUsersMobileScreen: React.FC = () => {
} }
if (Object.keys(updates).length > 0) { if (Object.keys(updates).length > 0) {
updateProfileMutation.mutate( updateProfileMutation.mutate(
{ auth0Sub: selectedUser.auth0Sub, data: updates }, { userId: selectedUser.id, data: updates },
{ {
onSuccess: () => { onSuccess: () => {
setShowEditModal(false); setShowEditModal(false);
@@ -306,7 +306,7 @@ export const AdminUsersMobileScreen: React.FC = () => {
const handlePromoteConfirm = useCallback(() => { const handlePromoteConfirm = useCallback(() => {
if (selectedUser) { if (selectedUser) {
promoteToAdminMutation.mutate( promoteToAdminMutation.mutate(
{ auth0Sub: selectedUser.auth0Sub, data: { role: promoteRole } }, { userId: selectedUser.id, data: { role: promoteRole } },
{ {
onSuccess: () => { onSuccess: () => {
setShowPromoteModal(false); setShowPromoteModal(false);
@@ -332,7 +332,7 @@ export const AdminUsersMobileScreen: React.FC = () => {
const handleHardDeleteConfirm = useCallback(() => { const handleHardDeleteConfirm = useCallback(() => {
if (selectedUser && hardDeleteConfirmText === 'DELETE') { if (selectedUser && hardDeleteConfirmText === 'DELETE') {
hardDeleteMutation.mutate( hardDeleteMutation.mutate(
{ auth0Sub: selectedUser.auth0Sub, reason: hardDeleteReason || undefined }, { userId: selectedUser.id, reason: hardDeleteReason || undefined },
{ {
onSuccess: () => { onSuccess: () => {
setShowHardDeleteModal(false); setShowHardDeleteModal(false);
@@ -509,7 +509,7 @@ export const AdminUsersMobileScreen: React.FC = () => {
{users.length > 0 && ( {users.length > 0 && (
<div className="space-y-3"> <div className="space-y-3">
{users.map((user) => ( {users.map((user) => (
<GlassCard key={user.auth0Sub} padding="md"> <GlassCard key={user.id} padding="md">
<button <button
onClick={() => handleUserClick(user)} onClick={() => handleUserClick(user)}
className="w-full text-left min-h-[44px]" className="w-full text-left min-h-[44px]"
@@ -526,7 +526,7 @@ export const AdminUsersMobileScreen: React.FC = () => {
<VehicleCountBadge <VehicleCountBadge
count={user.vehicleCount} count={user.vehicleCount}
onClick={user.vehicleCount > 0 ? () => setExpandedUserId( onClick={user.vehicleCount > 0 ? () => setExpandedUserId(
expandedUserId === user.auth0Sub ? null : user.auth0Sub expandedUserId === user.id ? null : user.id
) : undefined} ) : undefined}
/> />
<StatusBadge active={!user.deactivatedAt} /> <StatusBadge active={!user.deactivatedAt} />
@@ -543,8 +543,8 @@ export const AdminUsersMobileScreen: React.FC = () => {
</div> </div>
</button> </button>
<UserVehiclesList <UserVehiclesList
auth0Sub={user.auth0Sub} userId={user.id}
isOpen={expandedUserId === user.auth0Sub} isOpen={expandedUserId === user.id}
/> />
</GlassCard> </GlassCard>
))} ))}

View File

@@ -5,7 +5,8 @@
// Admin user types // Admin user types
export interface AdminUser { export interface AdminUser {
auth0Sub: string; id: string;
userProfileId: string;
email: string; email: string;
role: string; role: string;
createdAt: string; createdAt: string;

View File

@@ -71,8 +71,8 @@ import { AdminSectionHeader } from '../../features/admin/components';
const PAGE_SIZE_OPTIONS = [10, 20, 50, 100]; const PAGE_SIZE_OPTIONS = [10, 20, 50, 100];
// Expandable vehicle row component // Expandable vehicle row component
const UserVehiclesRow: React.FC<{ auth0Sub: string; isOpen: boolean }> = ({ auth0Sub, isOpen }) => { const UserVehiclesRow: React.FC<{ userId: string; isOpen: boolean }> = ({ userId, isOpen }) => {
const { data, isLoading, error } = useUserVehicles(auth0Sub); const { data, isLoading, error } = useUserVehicles(userId);
if (!isOpen) return null; if (!isOpen) return null;
@@ -222,8 +222,8 @@ export const AdminUsersPage: React.FC = () => {
}, []); }, []);
const handleTierChange = useCallback( const handleTierChange = useCallback(
(auth0Sub: string, newTier: SubscriptionTier) => { (userId: string, newTier: SubscriptionTier) => {
updateTierMutation.mutate({ auth0Sub, data: { subscriptionTier: newTier } }); updateTierMutation.mutate({ userId, data: { subscriptionTier: newTier } });
}, },
[updateTierMutation] [updateTierMutation]
); );
@@ -246,7 +246,7 @@ export const AdminUsersPage: React.FC = () => {
const handleDeactivateConfirm = useCallback(() => { const handleDeactivateConfirm = useCallback(() => {
if (selectedUser) { if (selectedUser) {
deactivateMutation.mutate( deactivateMutation.mutate(
{ auth0Sub: selectedUser.auth0Sub, data: { reason: deactivateReason || undefined } }, { userId: selectedUser.id, data: { reason: deactivateReason || undefined } },
{ {
onSuccess: () => { onSuccess: () => {
setDeactivateDialogOpen(false); setDeactivateDialogOpen(false);
@@ -260,7 +260,7 @@ export const AdminUsersPage: React.FC = () => {
const handleReactivate = useCallback(() => { const handleReactivate = useCallback(() => {
if (selectedUser) { if (selectedUser) {
reactivateMutation.mutate(selectedUser.auth0Sub); reactivateMutation.mutate(selectedUser.id);
setAnchorEl(null); setAnchorEl(null);
setSelectedUser(null); setSelectedUser(null);
} }
@@ -286,7 +286,7 @@ export const AdminUsersPage: React.FC = () => {
} }
if (Object.keys(updates).length > 0) { if (Object.keys(updates).length > 0) {
updateProfileMutation.mutate( updateProfileMutation.mutate(
{ auth0Sub: selectedUser.auth0Sub, data: updates }, { userId: selectedUser.id, data: updates },
{ {
onSuccess: () => { onSuccess: () => {
setEditDialogOpen(false); setEditDialogOpen(false);
@@ -316,7 +316,7 @@ export const AdminUsersPage: React.FC = () => {
const handlePromoteConfirm = useCallback(() => { const handlePromoteConfirm = useCallback(() => {
if (selectedUser) { if (selectedUser) {
promoteToAdminMutation.mutate( promoteToAdminMutation.mutate(
{ auth0Sub: selectedUser.auth0Sub, data: { role: promoteRole } }, { userId: selectedUser.id, data: { role: promoteRole } },
{ {
onSuccess: () => { onSuccess: () => {
setPromoteDialogOpen(false); setPromoteDialogOpen(false);
@@ -342,7 +342,7 @@ export const AdminUsersPage: React.FC = () => {
const handleHardDeleteConfirm = useCallback(() => { const handleHardDeleteConfirm = useCallback(() => {
if (selectedUser && hardDeleteConfirmText === 'DELETE') { if (selectedUser && hardDeleteConfirmText === 'DELETE') {
hardDeleteMutation.mutate( hardDeleteMutation.mutate(
{ auth0Sub: selectedUser.auth0Sub, reason: hardDeleteReason || undefined }, { userId: selectedUser.id, reason: hardDeleteReason || undefined },
{ {
onSuccess: () => { onSuccess: () => {
setHardDeleteDialogOpen(false); setHardDeleteDialogOpen(false);
@@ -496,11 +496,11 @@ export const AdminUsersPage: React.FC = () => {
</TableHead> </TableHead>
<TableBody> <TableBody>
{users.map((user) => ( {users.map((user) => (
<React.Fragment key={user.auth0Sub}> <React.Fragment key={user.id}>
<TableRow <TableRow
sx={{ sx={{
opacity: user.deactivatedAt ? 0.6 : 1, opacity: user.deactivatedAt ? 0.6 : 1,
'& > *': { borderBottom: expandedRow === user.auth0Sub ? 'unset' : undefined }, '& > *': { borderBottom: expandedRow === user.id ? 'unset' : undefined },
}} }}
> >
<TableCell>{user.email}</TableCell> <TableCell>{user.email}</TableCell>
@@ -510,7 +510,7 @@ export const AdminUsersPage: React.FC = () => {
<Select <Select
value={user.subscriptionTier} value={user.subscriptionTier}
onChange={(e) => onChange={(e) =>
handleTierChange(user.auth0Sub, e.target.value as SubscriptionTier) handleTierChange(user.id, e.target.value as SubscriptionTier)
} }
disabled={!!user.deactivatedAt || updateTierMutation.isPending} disabled={!!user.deactivatedAt || updateTierMutation.isPending}
size="small" size="small"
@@ -527,12 +527,12 @@ export const AdminUsersPage: React.FC = () => {
<IconButton <IconButton
size="small" size="small"
onClick={() => setExpandedRow( onClick={() => setExpandedRow(
expandedRow === user.auth0Sub ? null : user.auth0Sub expandedRow === user.id ? null : user.id
)} )}
aria-label="show vehicles" aria-label="show vehicles"
sx={{ minWidth: 44, minHeight: 44 }} sx={{ minWidth: 44, minHeight: 44 }}
> >
{expandedRow === user.auth0Sub ? ( {expandedRow === user.id ? (
<KeyboardArrowUp fontSize="small" /> <KeyboardArrowUp fontSize="small" />
) : ( ) : (
<KeyboardArrowDown fontSize="small" /> <KeyboardArrowDown fontSize="small" />
@@ -569,8 +569,8 @@ export const AdminUsersPage: React.FC = () => {
</TableCell> </TableCell>
</TableRow> </TableRow>
<UserVehiclesRow <UserVehiclesRow
auth0Sub={user.auth0Sub} userId={user.id}
isOpen={expandedRow === user.auth0Sub} isOpen={expandedRow === user.id}
/> />
</React.Fragment> </React.Fragment>
))} ))}