chore: migrate user identity from auth0_sub to UUID #219
@@ -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)
|
||||||
|
|||||||
@@ -0,0 +1,404 @@
|
|||||||
|
-- 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;
|
||||||
|
|
||||||
|
-- 2a-fix. user_preferences has rows where user_id already contains user_profiles.id (UUID)
|
||||||
|
-- instead of auth0_sub. Match these directly by casting to UUID.
|
||||||
|
UPDATE user_preferences SET user_profile_id = up.id
|
||||||
|
FROM user_profiles up
|
||||||
|
WHERE user_preferences.user_id ~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
|
||||||
|
AND user_preferences.user_id::uuid = up.id
|
||||||
|
AND user_preferences.user_profile_id IS NULL;
|
||||||
|
|
||||||
|
-- Delete truly orphaned user_preferences (UUID user_id with no matching user_profile)
|
||||||
|
DELETE FROM user_preferences
|
||||||
|
WHERE user_profile_id IS NULL
|
||||||
|
AND user_id ~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM user_profiles WHERE id = user_preferences.user_id::uuid);
|
||||||
|
|
||||||
|
-- Deduplicate user_preferences: same user may have both an auth0_sub row and
|
||||||
|
-- a UUID row, both now mapping to the same user_profile_id. Keep the newest.
|
||||||
|
DELETE FROM user_preferences a
|
||||||
|
USING user_preferences b
|
||||||
|
WHERE a.user_profile_id = b.user_profile_id
|
||||||
|
AND a.user_profile_id IS NOT NULL
|
||||||
|
AND (a.updated_at < b.updated_at OR (a.updated_at = b.updated_at AND a.id < b.id));
|
||||||
|
|
||||||
|
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;
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
@@ -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') {
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -110,17 +110,17 @@ export class AuthController {
|
|||||||
*/
|
*/
|
||||||
async getVerifyStatus(request: FastifyRequest, reply: FastifyReply) {
|
async getVerifyStatus(request: FastifyRequest, reply: FastifyReply) {
|
||||||
try {
|
try {
|
||||||
const userId = (request as any).user.sub;
|
const auth0Sub = (request as any).user.sub;
|
||||||
|
|
||||||
const result = await this.authService.getVerifyStatus(userId);
|
const result = await this.authService.getVerifyStatus(auth0Sub);
|
||||||
|
|
||||||
logger.info('Verification status checked', { userId, emailVerified: result.emailVerified });
|
logger.info('Verification status checked', { userId: request.userContext?.userId, emailVerified: result.emailVerified });
|
||||||
|
|
||||||
return reply.code(200).send(result);
|
return reply.code(200).send(result);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error('Failed to get verification status', {
|
logger.error('Failed to get verification status', {
|
||||||
error,
|
error,
|
||||||
userId: (request as any).user?.sub,
|
userId: request.userContext?.userId,
|
||||||
});
|
});
|
||||||
|
|
||||||
return reply.code(500).send({
|
return reply.code(500).send({
|
||||||
@@ -137,17 +137,17 @@ export class AuthController {
|
|||||||
*/
|
*/
|
||||||
async resendVerification(request: FastifyRequest, reply: FastifyReply) {
|
async resendVerification(request: FastifyRequest, reply: FastifyReply) {
|
||||||
try {
|
try {
|
||||||
const userId = (request as any).user.sub;
|
const auth0Sub = (request as any).user.sub;
|
||||||
|
|
||||||
const result = await this.authService.resendVerification(userId);
|
const result = await this.authService.resendVerification(auth0Sub);
|
||||||
|
|
||||||
logger.info('Verification email resent', { userId });
|
logger.info('Verification email resent', { userId: request.userContext?.userId });
|
||||||
|
|
||||||
return reply.code(200).send(result);
|
return reply.code(200).send(result);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error('Failed to resend verification email', {
|
logger.error('Failed to resend verification email', {
|
||||||
error,
|
error,
|
||||||
userId: (request as any).user?.sub,
|
userId: request.userContext?.userId,
|
||||||
});
|
});
|
||||||
|
|
||||||
return reply.code(500).send({
|
return reply.code(500).send({
|
||||||
@@ -199,12 +199,14 @@ export class AuthController {
|
|||||||
*/
|
*/
|
||||||
async getUserStatus(request: FastifyRequest, reply: FastifyReply) {
|
async getUserStatus(request: FastifyRequest, reply: FastifyReply) {
|
||||||
try {
|
try {
|
||||||
const userId = (request as any).user.sub;
|
const auth0Sub = (request as any).user.sub;
|
||||||
|
const userId = request.userContext?.userId;
|
||||||
|
|
||||||
const result = await this.authService.getUserStatus(userId);
|
const result = await this.authService.getUserStatus(auth0Sub);
|
||||||
|
|
||||||
// Log login event to audit trail (called once per Auth0 callback)
|
// Log login event to audit trail (called once per Auth0 callback)
|
||||||
const ipAddress = this.getClientIp(request);
|
const ipAddress = this.getClientIp(request);
|
||||||
|
if (userId) {
|
||||||
await auditLogService.info(
|
await auditLogService.info(
|
||||||
'auth',
|
'auth',
|
||||||
userId,
|
userId,
|
||||||
@@ -213,9 +215,10 @@ export class AuthController {
|
|||||||
userId,
|
userId,
|
||||||
{ ipAddress }
|
{ ipAddress }
|
||||||
).catch(err => logger.error('Failed to log login audit event', { error: err }));
|
).catch(err => logger.error('Failed to log login audit event', { error: err }));
|
||||||
|
}
|
||||||
|
|
||||||
logger.info('User status retrieved', {
|
logger.info('User status retrieved', {
|
||||||
userId: userId.substring(0, 8) + '...',
|
userId: userId?.substring(0, 8) + '...',
|
||||||
emailVerified: result.emailVerified,
|
emailVerified: result.emailVerified,
|
||||||
onboardingCompleted: result.onboardingCompleted,
|
onboardingCompleted: result.onboardingCompleted,
|
||||||
});
|
});
|
||||||
@@ -224,7 +227,7 @@ export class AuthController {
|
|||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error('Failed to get user status', {
|
logger.error('Failed to get user status', {
|
||||||
error,
|
error,
|
||||||
userId: (request as any).user?.sub,
|
userId: request.userContext?.userId,
|
||||||
});
|
});
|
||||||
|
|
||||||
return reply.code(500).send({
|
return reply.code(500).send({
|
||||||
@@ -241,12 +244,12 @@ export class AuthController {
|
|||||||
*/
|
*/
|
||||||
async getSecurityStatus(request: FastifyRequest, reply: FastifyReply) {
|
async getSecurityStatus(request: FastifyRequest, reply: FastifyReply) {
|
||||||
try {
|
try {
|
||||||
const userId = (request as any).user.sub;
|
const auth0Sub = (request as any).user.sub;
|
||||||
|
|
||||||
const result = await this.authService.getSecurityStatus(userId);
|
const result = await this.authService.getSecurityStatus(auth0Sub);
|
||||||
|
|
||||||
logger.info('Security status retrieved', {
|
logger.info('Security status retrieved', {
|
||||||
userId: userId.substring(0, 8) + '...',
|
userId: request.userContext?.userId,
|
||||||
emailVerified: result.emailVerified,
|
emailVerified: result.emailVerified,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -254,7 +257,7 @@ export class AuthController {
|
|||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error('Failed to get security status', {
|
logger.error('Failed to get security status', {
|
||||||
error,
|
error,
|
||||||
userId: (request as any).user?.sub,
|
userId: request.userContext?.userId,
|
||||||
});
|
});
|
||||||
|
|
||||||
return reply.code(500).send({
|
return reply.code(500).send({
|
||||||
@@ -271,15 +274,17 @@ export class AuthController {
|
|||||||
*/
|
*/
|
||||||
async requestPasswordReset(request: FastifyRequest, reply: FastifyReply) {
|
async requestPasswordReset(request: FastifyRequest, reply: FastifyReply) {
|
||||||
try {
|
try {
|
||||||
const userId = (request as any).user.sub;
|
const auth0Sub = (request as any).user.sub;
|
||||||
|
const userId = request.userContext?.userId;
|
||||||
|
|
||||||
const result = await this.authService.requestPasswordReset(userId);
|
const result = await this.authService.requestPasswordReset(auth0Sub);
|
||||||
|
|
||||||
logger.info('Password reset email requested', {
|
logger.info('Password reset email requested', {
|
||||||
userId: userId.substring(0, 8) + '...',
|
userId: userId?.substring(0, 8) + '...',
|
||||||
});
|
});
|
||||||
|
|
||||||
// Log password reset request to unified audit log
|
// Log password reset request to unified audit log
|
||||||
|
if (userId) {
|
||||||
await auditLogService.info(
|
await auditLogService.info(
|
||||||
'auth',
|
'auth',
|
||||||
userId,
|
userId,
|
||||||
@@ -287,12 +292,13 @@ export class AuthController {
|
|||||||
'user',
|
'user',
|
||||||
userId
|
userId
|
||||||
).catch(err => logger.error('Failed to log password reset audit event', { error: err }));
|
).catch(err => logger.error('Failed to log password reset audit event', { error: err }));
|
||||||
|
}
|
||||||
|
|
||||||
return reply.code(200).send(result);
|
return reply.code(200).send(result);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error('Failed to request password reset', {
|
logger.error('Failed to request password reset', {
|
||||||
error,
|
error,
|
||||||
userId: (request as any).user?.sub,
|
userId: request.userContext?.userId,
|
||||||
});
|
});
|
||||||
|
|
||||||
return reply.code(500).send({
|
return reply.code(500).send({
|
||||||
@@ -312,10 +318,11 @@ export class AuthController {
|
|||||||
*/
|
*/
|
||||||
async trackLogout(request: FastifyRequest, reply: FastifyReply) {
|
async trackLogout(request: FastifyRequest, reply: FastifyReply) {
|
||||||
try {
|
try {
|
||||||
const userId = (request as any).user.sub;
|
const userId = request.userContext?.userId;
|
||||||
const ipAddress = this.getClientIp(request);
|
const ipAddress = this.getClientIp(request);
|
||||||
|
|
||||||
// Log logout event to audit trail
|
// Log logout event to audit trail
|
||||||
|
if (userId) {
|
||||||
await auditLogService.info(
|
await auditLogService.info(
|
||||||
'auth',
|
'auth',
|
||||||
userId,
|
userId,
|
||||||
@@ -324,9 +331,10 @@ export class AuthController {
|
|||||||
userId,
|
userId,
|
||||||
{ ipAddress }
|
{ ipAddress }
|
||||||
).catch(err => logger.error('Failed to log logout audit event', { error: err }));
|
).catch(err => logger.error('Failed to log logout audit event', { error: err }));
|
||||||
|
}
|
||||||
|
|
||||||
logger.info('User logout tracked', {
|
logger.info('User logout tracked', {
|
||||||
userId: userId.substring(0, 8) + '...',
|
userId: userId?.substring(0, 8) + '...',
|
||||||
});
|
});
|
||||||
|
|
||||||
return reply.code(200).send({ success: true });
|
return reply.code(200).send({ success: true });
|
||||||
@@ -334,7 +342,7 @@ export class AuthController {
|
|||||||
// Don't block logout on audit failure - always return success
|
// Don't block logout on audit failure - always return success
|
||||||
logger.error('Failed to track logout', {
|
logger.error('Failed to track logout', {
|
||||||
error,
|
error,
|
||||||
userId: (request as any).user?.sub,
|
userId: request.userContext?.userId,
|
||||||
});
|
});
|
||||||
|
|
||||||
return reply.code(200).send({ success: true });
|
return reply.code(200).send({ success: true });
|
||||||
|
|||||||
@@ -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' }),
|
||||||
|
|||||||
@@ -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(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export class DocumentsController {
|
|||||||
private readonly service = new DocumentsService();
|
private readonly service = new DocumentsService();
|
||||||
|
|
||||||
async list(request: FastifyRequest<{ Querystring: ListQuery }>, reply: FastifyReply) {
|
async list(request: FastifyRequest<{ Querystring: ListQuery }>, reply: FastifyReply) {
|
||||||
const userId = (request as any).user?.sub as string;
|
const userId = request.userContext!.userId;
|
||||||
|
|
||||||
logger.info('Documents list requested', {
|
logger.info('Documents list requested', {
|
||||||
operation: 'documents.list',
|
operation: 'documents.list',
|
||||||
@@ -43,7 +43,7 @@ export class DocumentsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async get(request: FastifyRequest<{ Params: IdParams }>, reply: FastifyReply) {
|
async get(request: FastifyRequest<{ Params: IdParams }>, reply: FastifyReply) {
|
||||||
const userId = (request as any).user?.sub as string;
|
const userId = request.userContext!.userId;
|
||||||
const documentId = request.params.id;
|
const documentId = request.params.id;
|
||||||
|
|
||||||
logger.info('Document get requested', {
|
logger.info('Document get requested', {
|
||||||
@@ -74,7 +74,7 @@ export class DocumentsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async create(request: FastifyRequest<{ Body: CreateBody }>, reply: FastifyReply) {
|
async create(request: FastifyRequest<{ Body: CreateBody }>, reply: FastifyReply) {
|
||||||
const userId = (request as any).user?.sub as string;
|
const userId = request.userContext!.userId;
|
||||||
const userTier: SubscriptionTier = request.userContext?.subscriptionTier || 'free';
|
const userTier: SubscriptionTier = request.userContext?.subscriptionTier || 'free';
|
||||||
|
|
||||||
logger.info('Document create requested', {
|
logger.info('Document create requested', {
|
||||||
@@ -120,7 +120,7 @@ export class DocumentsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async update(request: FastifyRequest<{ Params: IdParams; Body: UpdateBody }>, reply: FastifyReply) {
|
async update(request: FastifyRequest<{ Params: IdParams; Body: UpdateBody }>, reply: FastifyReply) {
|
||||||
const userId = (request as any).user?.sub as string;
|
const userId = request.userContext!.userId;
|
||||||
const userTier: SubscriptionTier = request.userContext?.subscriptionTier || 'free';
|
const userTier: SubscriptionTier = request.userContext?.subscriptionTier || 'free';
|
||||||
const documentId = request.params.id;
|
const documentId = request.params.id;
|
||||||
|
|
||||||
@@ -174,7 +174,7 @@ export class DocumentsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async remove(request: FastifyRequest<{ Params: IdParams }>, reply: FastifyReply) {
|
async remove(request: FastifyRequest<{ Params: IdParams }>, reply: FastifyReply) {
|
||||||
const userId = (request as any).user?.sub as string;
|
const userId = request.userContext!.userId;
|
||||||
const documentId = request.params.id;
|
const documentId = request.params.id;
|
||||||
|
|
||||||
logger.info('Document delete requested', {
|
logger.info('Document delete requested', {
|
||||||
@@ -221,7 +221,7 @@ export class DocumentsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async upload(request: FastifyRequest<{ Params: IdParams }>, reply: FastifyReply) {
|
async upload(request: FastifyRequest<{ Params: IdParams }>, reply: FastifyReply) {
|
||||||
const userId = (request as any).user?.sub as string;
|
const userId = request.userContext!.userId;
|
||||||
const documentId = request.params.id;
|
const documentId = request.params.id;
|
||||||
|
|
||||||
logger.info('Document upload requested', {
|
logger.info('Document upload requested', {
|
||||||
@@ -373,7 +373,7 @@ export class DocumentsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async download(request: FastifyRequest<{ Params: IdParams }>, reply: FastifyReply) {
|
async download(request: FastifyRequest<{ Params: IdParams }>, reply: FastifyReply) {
|
||||||
const userId = (request as any).user?.sub as string;
|
const userId = request.userContext!.userId;
|
||||||
const documentId = request.params.id;
|
const documentId = request.params.id;
|
||||||
|
|
||||||
logger.info('Document download requested', {
|
logger.info('Document download requested', {
|
||||||
@@ -423,7 +423,7 @@ export class DocumentsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async listByVehicle(request: FastifyRequest<{ Params: { vehicleId: string } }>, reply: FastifyReply) {
|
async listByVehicle(request: FastifyRequest<{ Params: { vehicleId: string } }>, reply: FastifyReply) {
|
||||||
const userId = (request as any).user?.sub as string;
|
const userId = request.userContext!.userId;
|
||||||
const vehicleId = request.params.vehicleId;
|
const vehicleId = request.params.vehicleId;
|
||||||
|
|
||||||
logger.info('Documents by vehicle requested', {
|
logger.info('Documents by vehicle requested', {
|
||||||
@@ -457,7 +457,7 @@ export class DocumentsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async addVehicle(request: FastifyRequest<{ Params: { id: string; vehicleId: string } }>, reply: FastifyReply) {
|
async addVehicle(request: FastifyRequest<{ Params: { id: string; vehicleId: string } }>, reply: FastifyReply) {
|
||||||
const userId = (request as any).user?.sub as string;
|
const userId = request.userContext!.userId;
|
||||||
const { id: documentId, vehicleId } = request.params;
|
const { id: documentId, vehicleId } = request.params;
|
||||||
|
|
||||||
logger.info('Add vehicle to document requested', {
|
logger.info('Add vehicle to document requested', {
|
||||||
@@ -523,7 +523,7 @@ export class DocumentsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async removeVehicle(request: FastifyRequest<{ Params: { id: string; vehicleId: string } }>, reply: FastifyReply) {
|
async removeVehicle(request: FastifyRequest<{ Params: { id: string; vehicleId: string } }>, reply: FastifyReply) {
|
||||||
const userId = (request as any).user?.sub as string;
|
const userId = request.userContext!.userId;
|
||||||
const { id: documentId, vehicleId } = request.params;
|
const { id: documentId, vehicleId } = request.params;
|
||||||
|
|
||||||
logger.info('Remove vehicle from document requested', {
|
logger.info('Remove vehicle from document requested', {
|
||||||
|
|||||||
@@ -27,22 +27,22 @@ export class EmailIngestionController {
|
|||||||
|
|
||||||
async getPendingAssociations(request: FastifyRequest, reply: FastifyReply): Promise<void> {
|
async getPendingAssociations(request: FastifyRequest, reply: FastifyReply): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const userId = (request as any).user.sub;
|
const userId = request.userContext!.userId;
|
||||||
const associations = await this.repository.getPendingAssociations(userId);
|
const associations = await this.repository.getPendingAssociations(userId);
|
||||||
return reply.code(200).send(associations);
|
return reply.code(200).send(associations);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error('Error listing pending associations', { error: error.message, userId: (request as any).user?.sub });
|
logger.error('Error listing pending associations', { error: error.message, userId: request.userContext?.userId });
|
||||||
return reply.code(500).send({ error: 'Failed to list pending associations' });
|
return reply.code(500).send({ error: 'Failed to list pending associations' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async getPendingAssociationCount(request: FastifyRequest, reply: FastifyReply): Promise<void> {
|
async getPendingAssociationCount(request: FastifyRequest, reply: FastifyReply): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const userId = (request as any).user.sub;
|
const userId = request.userContext!.userId;
|
||||||
const count = await this.repository.getPendingAssociationCount(userId);
|
const count = await this.repository.getPendingAssociationCount(userId);
|
||||||
return reply.code(200).send({ count });
|
return reply.code(200).send({ count });
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error('Error counting pending associations', { error: error.message, userId: (request as any).user?.sub });
|
logger.error('Error counting pending associations', { error: error.message, userId: request.userContext?.userId });
|
||||||
return reply.code(500).send({ error: 'Failed to count pending associations' });
|
return reply.code(500).send({ error: 'Failed to count pending associations' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -52,7 +52,7 @@ export class EmailIngestionController {
|
|||||||
reply: FastifyReply
|
reply: FastifyReply
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const userId = (request as any).user.sub;
|
const userId = request.userContext!.userId;
|
||||||
const { id } = request.params;
|
const { id } = request.params;
|
||||||
const { vehicleId } = request.body;
|
const { vehicleId } = request.body;
|
||||||
|
|
||||||
@@ -63,7 +63,7 @@ export class EmailIngestionController {
|
|||||||
const result = await this.service.resolveAssociation(id, vehicleId, userId);
|
const result = await this.service.resolveAssociation(id, vehicleId, userId);
|
||||||
return reply.code(200).send(result);
|
return reply.code(200).send(result);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
const userId = (request as any).user?.sub;
|
const userId = request.userContext?.userId;
|
||||||
logger.error('Error resolving pending association', {
|
logger.error('Error resolving pending association', {
|
||||||
error: error.message,
|
error: error.message,
|
||||||
associationId: request.params.id,
|
associationId: request.params.id,
|
||||||
@@ -89,13 +89,13 @@ export class EmailIngestionController {
|
|||||||
reply: FastifyReply
|
reply: FastifyReply
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const userId = (request as any).user.sub;
|
const userId = request.userContext!.userId;
|
||||||
const { id } = request.params;
|
const { id } = request.params;
|
||||||
|
|
||||||
await this.service.dismissAssociation(id, userId);
|
await this.service.dismissAssociation(id, userId);
|
||||||
return reply.code(204).send();
|
return reply.code(204).send();
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
const userId = (request as any).user?.sub;
|
const userId = request.userContext?.userId;
|
||||||
logger.error('Error dismissing pending association', {
|
logger.error('Error dismissing pending association', {
|
||||||
error: error.message,
|
error: error.message,
|
||||||
associationId: request.params.id,
|
associationId: request.params.id,
|
||||||
|
|||||||
@@ -20,12 +20,12 @@ export class FuelLogsController {
|
|||||||
|
|
||||||
async createFuelLog(request: FastifyRequest<{ Body: EnhancedCreateFuelLogRequest }>, reply: FastifyReply) {
|
async createFuelLog(request: FastifyRequest<{ Body: EnhancedCreateFuelLogRequest }>, reply: FastifyReply) {
|
||||||
try {
|
try {
|
||||||
const userId = (request as any).user.sub;
|
const userId = request.userContext!.userId;
|
||||||
const fuelLog = await this.fuelLogsService.createFuelLog(request.body, userId);
|
const fuelLog = await this.fuelLogsService.createFuelLog(request.body, userId);
|
||||||
|
|
||||||
return reply.code(201).send(fuelLog);
|
return reply.code(201).send(fuelLog);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error('Error creating fuel log', { error, userId: (request as any).user?.sub });
|
logger.error('Error creating fuel log', { error, userId: request.userContext?.userId });
|
||||||
|
|
||||||
if (error.message.includes('not found')) {
|
if (error.message.includes('not found')) {
|
||||||
return reply.code(404).send({
|
return reply.code(404).send({
|
||||||
@@ -49,14 +49,14 @@ export class FuelLogsController {
|
|||||||
|
|
||||||
async getFuelLogsByVehicle(request: FastifyRequest<{ Params: VehicleParams }>, reply: FastifyReply) {
|
async getFuelLogsByVehicle(request: FastifyRequest<{ Params: VehicleParams }>, reply: FastifyReply) {
|
||||||
try {
|
try {
|
||||||
const userId = (request as any).user.sub;
|
const userId = request.userContext!.userId;
|
||||||
const { vehicleId } = request.params;
|
const { vehicleId } = request.params;
|
||||||
|
|
||||||
const fuelLogs = await this.fuelLogsService.getFuelLogsByVehicle(vehicleId, userId);
|
const fuelLogs = await this.fuelLogsService.getFuelLogsByVehicle(vehicleId, userId);
|
||||||
|
|
||||||
return reply.code(200).send(fuelLogs);
|
return reply.code(200).send(fuelLogs);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error('Error listing fuel logs', { error, vehicleId: request.params.vehicleId, userId: (request as any).user?.sub });
|
logger.error('Error listing fuel logs', { error, vehicleId: request.params.vehicleId, userId: request.userContext?.userId });
|
||||||
|
|
||||||
if (error.message.includes('not found')) {
|
if (error.message.includes('not found')) {
|
||||||
return reply.code(404).send({
|
return reply.code(404).send({
|
||||||
@@ -80,12 +80,12 @@ export class FuelLogsController {
|
|||||||
|
|
||||||
async getUserFuelLogs(request: FastifyRequest, reply: FastifyReply) {
|
async getUserFuelLogs(request: FastifyRequest, reply: FastifyReply) {
|
||||||
try {
|
try {
|
||||||
const userId = (request as any).user.sub;
|
const userId = request.userContext!.userId;
|
||||||
const fuelLogs = await this.fuelLogsService.getUserFuelLogs(userId);
|
const fuelLogs = await this.fuelLogsService.getUserFuelLogs(userId);
|
||||||
|
|
||||||
return reply.code(200).send(fuelLogs);
|
return reply.code(200).send(fuelLogs);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error('Error listing all fuel logs', { error, userId: (request as any).user?.sub });
|
logger.error('Error listing all fuel logs', { error, userId: request.userContext?.userId });
|
||||||
return reply.code(500).send({
|
return reply.code(500).send({
|
||||||
error: 'Internal server error',
|
error: 'Internal server error',
|
||||||
message: 'Failed to get fuel logs'
|
message: 'Failed to get fuel logs'
|
||||||
@@ -95,14 +95,14 @@ export class FuelLogsController {
|
|||||||
|
|
||||||
async getFuelLog(request: FastifyRequest<{ Params: FuelLogParams }>, reply: FastifyReply) {
|
async getFuelLog(request: FastifyRequest<{ Params: FuelLogParams }>, reply: FastifyReply) {
|
||||||
try {
|
try {
|
||||||
const userId = (request as any).user.sub;
|
const userId = request.userContext!.userId;
|
||||||
const { id } = request.params;
|
const { id } = request.params;
|
||||||
|
|
||||||
const fuelLog = await this.fuelLogsService.getFuelLog(id, userId);
|
const fuelLog = await this.fuelLogsService.getFuelLog(id, userId);
|
||||||
|
|
||||||
return reply.code(200).send(fuelLog);
|
return reply.code(200).send(fuelLog);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error('Error getting fuel log', { error, fuelLogId: request.params.id, userId: (request as any).user?.sub });
|
logger.error('Error getting fuel log', { error, fuelLogId: request.params.id, userId: request.userContext?.userId });
|
||||||
|
|
||||||
if (error.message === 'Fuel log not found') {
|
if (error.message === 'Fuel log not found') {
|
||||||
return reply.code(404).send({
|
return reply.code(404).send({
|
||||||
@@ -126,14 +126,14 @@ export class FuelLogsController {
|
|||||||
|
|
||||||
async updateFuelLog(request: FastifyRequest<{ Params: FuelLogParams; Body: EnhancedUpdateFuelLogRequest }>, reply: FastifyReply) {
|
async updateFuelLog(request: FastifyRequest<{ Params: FuelLogParams; Body: EnhancedUpdateFuelLogRequest }>, reply: FastifyReply) {
|
||||||
try {
|
try {
|
||||||
const userId = (request as any).user.sub;
|
const userId = request.userContext!.userId;
|
||||||
const { id } = request.params;
|
const { id } = request.params;
|
||||||
|
|
||||||
const updatedFuelLog = await this.fuelLogsService.updateFuelLog(id, request.body, userId);
|
const updatedFuelLog = await this.fuelLogsService.updateFuelLog(id, request.body, userId);
|
||||||
|
|
||||||
return reply.code(200).send(updatedFuelLog);
|
return reply.code(200).send(updatedFuelLog);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error('Error updating fuel log', { error, fuelLogId: request.params.id, userId: (request as any).user?.sub });
|
logger.error('Error updating fuel log', { error, fuelLogId: request.params.id, userId: request.userContext?.userId });
|
||||||
|
|
||||||
if (error.message.includes('not found')) {
|
if (error.message.includes('not found')) {
|
||||||
return reply.code(404).send({
|
return reply.code(404).send({
|
||||||
@@ -163,14 +163,14 @@ export class FuelLogsController {
|
|||||||
|
|
||||||
async deleteFuelLog(request: FastifyRequest<{ Params: FuelLogParams }>, reply: FastifyReply) {
|
async deleteFuelLog(request: FastifyRequest<{ Params: FuelLogParams }>, reply: FastifyReply) {
|
||||||
try {
|
try {
|
||||||
const userId = (request as any).user.sub;
|
const userId = request.userContext!.userId;
|
||||||
const { id } = request.params;
|
const { id } = request.params;
|
||||||
|
|
||||||
await this.fuelLogsService.deleteFuelLog(id, userId);
|
await this.fuelLogsService.deleteFuelLog(id, userId);
|
||||||
|
|
||||||
return reply.code(204).send();
|
return reply.code(204).send();
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error('Error deleting fuel log', { error, fuelLogId: request.params.id, userId: (request as any).user?.sub });
|
logger.error('Error deleting fuel log', { error, fuelLogId: request.params.id, userId: request.userContext?.userId });
|
||||||
|
|
||||||
if (error.message.includes('not found')) {
|
if (error.message.includes('not found')) {
|
||||||
return reply.code(404).send({
|
return reply.code(404).send({
|
||||||
@@ -194,14 +194,14 @@ export class FuelLogsController {
|
|||||||
|
|
||||||
async getFuelStats(request: FastifyRequest<{ Params: VehicleParams }>, reply: FastifyReply) {
|
async getFuelStats(request: FastifyRequest<{ Params: VehicleParams }>, reply: FastifyReply) {
|
||||||
try {
|
try {
|
||||||
const userId = (request as any).user.sub;
|
const userId = request.userContext!.userId;
|
||||||
const { vehicleId } = request.params;
|
const { vehicleId } = request.params;
|
||||||
|
|
||||||
const stats = await this.fuelLogsService.getVehicleStats(vehicleId, userId);
|
const stats = await this.fuelLogsService.getVehicleStats(vehicleId, userId);
|
||||||
|
|
||||||
return reply.code(200).send(stats);
|
return reply.code(200).send(stats);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error('Error getting fuel stats', { error, vehicleId: request.params.vehicleId, userId: (request as any).user?.sub });
|
logger.error('Error getting fuel stats', { error, vehicleId: request.params.vehicleId, userId: request.userContext?.userId });
|
||||||
|
|
||||||
if (error.message.includes('not found')) {
|
if (error.message.includes('not found')) {
|
||||||
return reply.code(404).send({
|
return reply.code(404).send({
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export class MaintenanceController {
|
|||||||
request: FastifyRequest<{ Querystring: { vehicleId?: string; category?: string } }>,
|
request: FastifyRequest<{ Querystring: { vehicleId?: string; category?: string } }>,
|
||||||
reply: FastifyReply
|
reply: FastifyReply
|
||||||
) {
|
) {
|
||||||
const userId = (request as any).user?.sub as string;
|
const userId = request.userContext!.userId;
|
||||||
|
|
||||||
logger.info('Maintenance records list requested', {
|
logger.info('Maintenance records list requested', {
|
||||||
operation: 'maintenance.records.list',
|
operation: 'maintenance.records.list',
|
||||||
@@ -58,7 +58,7 @@ export class MaintenanceController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getRecord(request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) {
|
async getRecord(request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) {
|
||||||
const userId = (request as any).user?.sub as string;
|
const userId = request.userContext!.userId;
|
||||||
const recordId = request.params.id;
|
const recordId = request.params.id;
|
||||||
|
|
||||||
logger.info('Maintenance record get requested', {
|
logger.info('Maintenance record get requested', {
|
||||||
@@ -102,7 +102,7 @@ export class MaintenanceController {
|
|||||||
request: FastifyRequest<{ Params: { vehicleId: string } }>,
|
request: FastifyRequest<{ Params: { vehicleId: string } }>,
|
||||||
reply: FastifyReply
|
reply: FastifyReply
|
||||||
) {
|
) {
|
||||||
const userId = (request as any).user?.sub as string;
|
const userId = request.userContext!.userId;
|
||||||
const vehicleId = request.params.vehicleId;
|
const vehicleId = request.params.vehicleId;
|
||||||
|
|
||||||
logger.info('Maintenance records by vehicle requested', {
|
logger.info('Maintenance records by vehicle requested', {
|
||||||
@@ -134,7 +134,7 @@ export class MaintenanceController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async createRecord(request: FastifyRequest<{ Body: unknown }>, reply: FastifyReply) {
|
async createRecord(request: FastifyRequest<{ Body: unknown }>, reply: FastifyReply) {
|
||||||
const userId = (request as any).user?.sub as string;
|
const userId = request.userContext!.userId;
|
||||||
|
|
||||||
logger.info('Maintenance record create requested', {
|
logger.info('Maintenance record create requested', {
|
||||||
operation: 'maintenance.records.create',
|
operation: 'maintenance.records.create',
|
||||||
@@ -190,7 +190,7 @@ export class MaintenanceController {
|
|||||||
request: FastifyRequest<{ Params: { id: string }; Body: unknown }>,
|
request: FastifyRequest<{ Params: { id: string }; Body: unknown }>,
|
||||||
reply: FastifyReply
|
reply: FastifyReply
|
||||||
) {
|
) {
|
||||||
const userId = (request as any).user?.sub as string;
|
const userId = request.userContext!.userId;
|
||||||
const recordId = request.params.id;
|
const recordId = request.params.id;
|
||||||
|
|
||||||
logger.info('Maintenance record update requested', {
|
logger.info('Maintenance record update requested', {
|
||||||
@@ -255,7 +255,7 @@ export class MaintenanceController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async deleteRecord(request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) {
|
async deleteRecord(request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) {
|
||||||
const userId = (request as any).user?.sub as string;
|
const userId = request.userContext!.userId;
|
||||||
const recordId = request.params.id;
|
const recordId = request.params.id;
|
||||||
|
|
||||||
logger.info('Maintenance record delete requested', {
|
logger.info('Maintenance record delete requested', {
|
||||||
@@ -289,7 +289,7 @@ export class MaintenanceController {
|
|||||||
request: FastifyRequest<{ Params: { vehicleId: string } }>,
|
request: FastifyRequest<{ Params: { vehicleId: string } }>,
|
||||||
reply: FastifyReply
|
reply: FastifyReply
|
||||||
) {
|
) {
|
||||||
const userId = (request as any).user?.sub as string;
|
const userId = request.userContext!.userId;
|
||||||
const vehicleId = request.params.vehicleId;
|
const vehicleId = request.params.vehicleId;
|
||||||
|
|
||||||
logger.info('Maintenance schedules by vehicle requested', {
|
logger.info('Maintenance schedules by vehicle requested', {
|
||||||
@@ -321,7 +321,7 @@ export class MaintenanceController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async createSchedule(request: FastifyRequest<{ Body: unknown }>, reply: FastifyReply) {
|
async createSchedule(request: FastifyRequest<{ Body: unknown }>, reply: FastifyReply) {
|
||||||
const userId = (request as any).user?.sub as string;
|
const userId = request.userContext!.userId;
|
||||||
|
|
||||||
logger.info('Maintenance schedule create requested', {
|
logger.info('Maintenance schedule create requested', {
|
||||||
operation: 'maintenance.schedules.create',
|
operation: 'maintenance.schedules.create',
|
||||||
@@ -377,7 +377,7 @@ export class MaintenanceController {
|
|||||||
request: FastifyRequest<{ Params: { id: string }; Body: unknown }>,
|
request: FastifyRequest<{ Params: { id: string }; Body: unknown }>,
|
||||||
reply: FastifyReply
|
reply: FastifyReply
|
||||||
) {
|
) {
|
||||||
const userId = (request as any).user?.sub as string;
|
const userId = request.userContext!.userId;
|
||||||
const scheduleId = request.params.id;
|
const scheduleId = request.params.id;
|
||||||
|
|
||||||
logger.info('Maintenance schedule update requested', {
|
logger.info('Maintenance schedule update requested', {
|
||||||
@@ -442,7 +442,7 @@ export class MaintenanceController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async deleteSchedule(request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) {
|
async deleteSchedule(request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) {
|
||||||
const userId = (request as any).user?.sub as string;
|
const userId = request.userContext!.userId;
|
||||||
const scheduleId = request.params.id;
|
const scheduleId = request.params.id;
|
||||||
|
|
||||||
logger.info('Maintenance schedule delete requested', {
|
logger.info('Maintenance schedule delete requested', {
|
||||||
@@ -476,7 +476,7 @@ export class MaintenanceController {
|
|||||||
request: FastifyRequest<{ Params: { vehicleId: string }; Querystring: { currentMileage?: string } }>,
|
request: FastifyRequest<{ Params: { vehicleId: string }; Querystring: { currentMileage?: string } }>,
|
||||||
reply: FastifyReply
|
reply: FastifyReply
|
||||||
) {
|
) {
|
||||||
const userId = (request as any).user?.sub as string;
|
const userId = request.userContext!.userId;
|
||||||
const vehicleId = request.params.vehicleId;
|
const vehicleId = request.params.vehicleId;
|
||||||
const currentMileage = request.query.currentMileage ? parseInt(request.query.currentMileage, 10) : undefined;
|
const currentMileage = request.query.currentMileage ? parseInt(request.query.currentMileage, 10) : undefined;
|
||||||
|
|
||||||
@@ -510,7 +510,7 @@ export class MaintenanceController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getSubtypes(request: FastifyRequest<{ Params: { category: string } }>, reply: FastifyReply) {
|
async getSubtypes(request: FastifyRequest<{ Params: { category: string } }>, reply: FastifyReply) {
|
||||||
const userId = (request as any).user?.sub as string;
|
const userId = request.userContext!.userId;
|
||||||
const category = request.params.category;
|
const category = request.params.category;
|
||||||
|
|
||||||
logger.info('Maintenance subtypes requested', {
|
logger.info('Maintenance subtypes requested', {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export class NotificationsController {
|
|||||||
// ========================
|
// ========================
|
||||||
|
|
||||||
async getSummary(request: FastifyRequest, reply: FastifyReply) {
|
async getSummary(request: FastifyRequest, reply: FastifyReply) {
|
||||||
const userId = request.user.sub;
|
const userId = request.userContext!.userId;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const summary = await this.service.getNotificationSummary(userId);
|
const summary = await this.service.getNotificationSummary(userId);
|
||||||
@@ -38,7 +38,7 @@ export class NotificationsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getDueMaintenanceItems(request: FastifyRequest, reply: FastifyReply) {
|
async getDueMaintenanceItems(request: FastifyRequest, reply: FastifyReply) {
|
||||||
const userId = request.user.sub;
|
const userId = request.userContext!.userId;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const items = await this.service.getDueMaintenanceItems(userId);
|
const items = await this.service.getDueMaintenanceItems(userId);
|
||||||
@@ -52,7 +52,7 @@ export class NotificationsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getExpiringDocuments(request: FastifyRequest, reply: FastifyReply) {
|
async getExpiringDocuments(request: FastifyRequest, reply: FastifyReply) {
|
||||||
const userId = request.user.sub;
|
const userId = request.userContext!.userId;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const documents = await this.service.getExpiringDocuments(userId);
|
const documents = await this.service.getExpiringDocuments(userId);
|
||||||
@@ -70,7 +70,7 @@ export class NotificationsController {
|
|||||||
// ========================
|
// ========================
|
||||||
|
|
||||||
async getInAppNotifications(request: FastifyRequest, reply: FastifyReply) {
|
async getInAppNotifications(request: FastifyRequest, reply: FastifyReply) {
|
||||||
const userId = request.user!.sub!;
|
const userId = request.userContext!.userId;
|
||||||
const query = request.query as { limit?: string; includeRead?: string };
|
const query = request.query as { limit?: string; includeRead?: string };
|
||||||
const limit = query.limit ? parseInt(query.limit, 10) : 20;
|
const limit = query.limit ? parseInt(query.limit, 10) : 20;
|
||||||
const includeRead = query.includeRead === 'true';
|
const includeRead = query.includeRead === 'true';
|
||||||
@@ -85,7 +85,7 @@ export class NotificationsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getUnreadCount(request: FastifyRequest, reply: FastifyReply) {
|
async getUnreadCount(request: FastifyRequest, reply: FastifyReply) {
|
||||||
const userId = request.user!.sub!;
|
const userId = request.userContext!.userId;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const count = await this.service.getUnreadCount(userId);
|
const count = await this.service.getUnreadCount(userId);
|
||||||
@@ -97,7 +97,7 @@ export class NotificationsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async markAsRead(request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) {
|
async markAsRead(request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) {
|
||||||
const userId = request.user!.sub!;
|
const userId = request.userContext!.userId;
|
||||||
const notificationId = request.params.id;
|
const notificationId = request.params.id;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -113,7 +113,7 @@ export class NotificationsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async markAllAsRead(request: FastifyRequest, reply: FastifyReply) {
|
async markAllAsRead(request: FastifyRequest, reply: FastifyReply) {
|
||||||
const userId = request.user!.sub!;
|
const userId = request.userContext!.userId;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const count = await this.service.markAllAsRead(userId);
|
const count = await this.service.markAllAsRead(userId);
|
||||||
@@ -125,7 +125,7 @@ export class NotificationsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async deleteNotification(request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) {
|
async deleteNotification(request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) {
|
||||||
const userId = request.user!.sub!;
|
const userId = request.userContext!.userId;
|
||||||
const notificationId = request.params.id;
|
const notificationId = request.params.id;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -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', {
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ export class OnboardingController {
|
|||||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||||
logger.error('Error in savePreferences controller', {
|
logger.error('Error in savePreferences controller', {
|
||||||
error,
|
error,
|
||||||
userId: (request as AuthenticatedRequest).user?.sub,
|
userId: request.userContext?.userId,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (errorMessage === 'User profile not found') {
|
if (errorMessage === 'User profile not found') {
|
||||||
@@ -86,7 +86,7 @@ export class OnboardingController {
|
|||||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||||
logger.error('Error in completeOnboarding controller', {
|
logger.error('Error in completeOnboarding controller', {
|
||||||
error,
|
error,
|
||||||
userId: (request as AuthenticatedRequest).user?.sub,
|
userId: request.userContext?.userId,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (errorMessage === 'User profile not found') {
|
if (errorMessage === 'User profile not found') {
|
||||||
@@ -124,7 +124,7 @@ export class OnboardingController {
|
|||||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||||
logger.error('Error in getStatus controller', {
|
logger.error('Error in getStatus controller', {
|
||||||
error,
|
error,
|
||||||
userId: (request as AuthenticatedRequest).user?.sub,
|
userId: request.userContext?.userId,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (errorMessage === 'User profile not found') {
|
if (errorMessage === 'User profile not found') {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export class OwnershipCostsController {
|
|||||||
private readonly service = new OwnershipCostsService();
|
private readonly service = new OwnershipCostsService();
|
||||||
|
|
||||||
async list(request: FastifyRequest<{ Querystring: ListQuery }>, reply: FastifyReply) {
|
async list(request: FastifyRequest<{ Querystring: ListQuery }>, reply: FastifyReply) {
|
||||||
const userId = (request as any).user?.sub as string;
|
const userId = request.userContext!.userId;
|
||||||
|
|
||||||
logger.info('Ownership costs list requested', {
|
logger.info('Ownership costs list requested', {
|
||||||
operation: 'ownership-costs.list',
|
operation: 'ownership-costs.list',
|
||||||
@@ -35,7 +35,7 @@ export class OwnershipCostsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async get(request: FastifyRequest<{ Params: IdParams }>, reply: FastifyReply) {
|
async get(request: FastifyRequest<{ Params: IdParams }>, reply: FastifyReply) {
|
||||||
const userId = (request as any).user?.sub as string;
|
const userId = request.userContext!.userId;
|
||||||
const costId = request.params.id;
|
const costId = request.params.id;
|
||||||
|
|
||||||
logger.info('Ownership cost get requested', {
|
logger.info('Ownership cost get requested', {
|
||||||
@@ -66,7 +66,7 @@ export class OwnershipCostsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async create(request: FastifyRequest<{ Body: CreateBody }>, reply: FastifyReply) {
|
async create(request: FastifyRequest<{ Body: CreateBody }>, reply: FastifyReply) {
|
||||||
const userId = (request as any).user?.sub as string;
|
const userId = request.userContext!.userId;
|
||||||
|
|
||||||
logger.info('Ownership cost create requested', {
|
logger.info('Ownership cost create requested', {
|
||||||
operation: 'ownership-costs.create',
|
operation: 'ownership-costs.create',
|
||||||
@@ -91,7 +91,7 @@ export class OwnershipCostsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async update(request: FastifyRequest<{ Params: IdParams; Body: UpdateBody }>, reply: FastifyReply) {
|
async update(request: FastifyRequest<{ Params: IdParams; Body: UpdateBody }>, reply: FastifyReply) {
|
||||||
const userId = (request as any).user?.sub as string;
|
const userId = request.userContext!.userId;
|
||||||
const costId = request.params.id;
|
const costId = request.params.id;
|
||||||
|
|
||||||
logger.info('Ownership cost update requested', {
|
logger.info('Ownership cost update requested', {
|
||||||
@@ -123,7 +123,7 @@ export class OwnershipCostsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async remove(request: FastifyRequest<{ Params: IdParams }>, reply: FastifyReply) {
|
async remove(request: FastifyRequest<{ Params: IdParams }>, reply: FastifyReply) {
|
||||||
const userId = (request as any).user?.sub as string;
|
const userId = request.userContext!.userId;
|
||||||
const costId = request.params.id;
|
const costId = request.params.id;
|
||||||
|
|
||||||
logger.info('Ownership cost delete requested', {
|
logger.info('Ownership cost delete requested', {
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ export class CommunityStationsController {
|
|||||||
reply: FastifyReply
|
reply: FastifyReply
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const userId = (request as any).user.sub;
|
const userId = request.userContext!.userId;
|
||||||
|
|
||||||
// Validate request body
|
// Validate request body
|
||||||
const validation = submitCommunityStationSchema.safeParse(request.body);
|
const validation = submitCommunityStationSchema.safeParse(request.body);
|
||||||
@@ -62,7 +62,7 @@ export class CommunityStationsController {
|
|||||||
|
|
||||||
return reply.code(201).send(station);
|
return reply.code(201).send(station);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error('Error submitting station', { error, userId: (request as any).user?.sub });
|
logger.error('Error submitting station', { error, userId: request.userContext?.userId });
|
||||||
return reply.code(500).send({
|
return reply.code(500).send({
|
||||||
error: 'Internal server error',
|
error: 'Internal server error',
|
||||||
message: 'Failed to submit station'
|
message: 'Failed to submit station'
|
||||||
@@ -79,7 +79,7 @@ export class CommunityStationsController {
|
|||||||
reply: FastifyReply
|
reply: FastifyReply
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const userId = (request as any).user.sub;
|
const userId = request.userContext!.userId;
|
||||||
|
|
||||||
// Validate query params
|
// Validate query params
|
||||||
const validation = paginationSchema.safeParse(request.query);
|
const validation = paginationSchema.safeParse(request.query);
|
||||||
@@ -94,7 +94,7 @@ export class CommunityStationsController {
|
|||||||
|
|
||||||
return reply.code(200).send(result);
|
return reply.code(200).send(result);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error('Error getting user submissions', { error, userId: (request as any).user?.sub });
|
logger.error('Error getting user submissions', { error, userId: request.userContext?.userId });
|
||||||
return reply.code(500).send({
|
return reply.code(500).send({
|
||||||
error: 'Internal server error',
|
error: 'Internal server error',
|
||||||
message: 'Failed to retrieve submissions'
|
message: 'Failed to retrieve submissions'
|
||||||
@@ -111,7 +111,7 @@ export class CommunityStationsController {
|
|||||||
reply: FastifyReply
|
reply: FastifyReply
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const userId = (request as any).user.sub;
|
const userId = request.userContext!.userId;
|
||||||
|
|
||||||
// Validate params
|
// Validate params
|
||||||
const validation = stationIdSchema.safeParse(request.params);
|
const validation = stationIdSchema.safeParse(request.params);
|
||||||
@@ -128,7 +128,7 @@ export class CommunityStationsController {
|
|||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error('Error withdrawing submission', {
|
logger.error('Error withdrawing submission', {
|
||||||
error,
|
error,
|
||||||
userId: (request as any).user?.sub,
|
userId: request.userContext?.userId,
|
||||||
stationId: request.params.id
|
stationId: request.params.id
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -252,7 +252,7 @@ export class CommunityStationsController {
|
|||||||
reply: FastifyReply
|
reply: FastifyReply
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const userId = (request as any).user.sub;
|
const userId = request.userContext!.userId;
|
||||||
|
|
||||||
// Validate params
|
// Validate params
|
||||||
const paramsValidation = stationIdSchema.safeParse(request.params);
|
const paramsValidation = stationIdSchema.safeParse(request.params);
|
||||||
@@ -280,7 +280,7 @@ export class CommunityStationsController {
|
|||||||
|
|
||||||
return reply.code(200).send(result);
|
return reply.code(200).send(result);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error('Error reporting removal', { error, userId: (request as any).user?.sub });
|
logger.error('Error reporting removal', { error, userId: request.userContext?.userId });
|
||||||
|
|
||||||
if (error.message.includes('not found')) {
|
if (error.message.includes('not found')) {
|
||||||
return reply.code(404).send({
|
return reply.code(404).send({
|
||||||
@@ -379,7 +379,7 @@ export class CommunityStationsController {
|
|||||||
reply: FastifyReply
|
reply: FastifyReply
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const adminId = (request as any).user.sub;
|
const adminId = request.userContext!.userId;
|
||||||
|
|
||||||
// Validate params
|
// Validate params
|
||||||
const paramsValidation = stationIdSchema.safeParse(request.params);
|
const paramsValidation = stationIdSchema.safeParse(request.params);
|
||||||
@@ -422,7 +422,7 @@ export class CommunityStationsController {
|
|||||||
|
|
||||||
return reply.code(200).send(station);
|
return reply.code(200).send(station);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error('Error reviewing station', { error, adminId: (request as any).user?.sub });
|
logger.error('Error reviewing station', { error, adminId: request.userContext?.userId });
|
||||||
|
|
||||||
if (error.message.includes('not found')) {
|
if (error.message.includes('not found')) {
|
||||||
return reply.code(404).send({
|
return reply.code(404).send({
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export class StationsController {
|
|||||||
|
|
||||||
async searchStations(request: FastifyRequest<{ Body: StationSearchBody }>, reply: FastifyReply) {
|
async searchStations(request: FastifyRequest<{ Body: StationSearchBody }>, reply: FastifyReply) {
|
||||||
try {
|
try {
|
||||||
const userId = (request as any).user.sub;
|
const userId = request.userContext!.userId;
|
||||||
const { latitude, longitude, radius, fuelType } = request.body;
|
const { latitude, longitude, radius, fuelType } = request.body;
|
||||||
|
|
||||||
if (!latitude || !longitude) {
|
if (!latitude || !longitude) {
|
||||||
@@ -46,7 +46,7 @@ export class StationsController {
|
|||||||
|
|
||||||
return reply.code(200).send(result);
|
return reply.code(200).send(result);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error('Error searching stations', { error, userId: (request as any).user?.sub });
|
logger.error('Error searching stations', { error, userId: request.userContext?.userId });
|
||||||
return reply.code(500).send({
|
return reply.code(500).send({
|
||||||
error: 'Internal server error',
|
error: 'Internal server error',
|
||||||
message: 'Failed to search stations'
|
message: 'Failed to search stations'
|
||||||
@@ -79,7 +79,7 @@ export class StationsController {
|
|||||||
|
|
||||||
async saveStation(request: FastifyRequest<{ Body: SaveStationBody }>, reply: FastifyReply) {
|
async saveStation(request: FastifyRequest<{ Body: SaveStationBody }>, reply: FastifyReply) {
|
||||||
try {
|
try {
|
||||||
const userId = (request as any).user.sub;
|
const userId = request.userContext!.userId;
|
||||||
const {
|
const {
|
||||||
placeId,
|
placeId,
|
||||||
nickname,
|
nickname,
|
||||||
@@ -106,7 +106,7 @@ export class StationsController {
|
|||||||
|
|
||||||
return reply.code(201).send(result);
|
return reply.code(201).send(result);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error('Error saving station', { error, userId: (request as any).user?.sub });
|
logger.error('Error saving station', { error, userId: request.userContext?.userId });
|
||||||
|
|
||||||
if (error.message.includes('not found')) {
|
if (error.message.includes('not found')) {
|
||||||
return reply.code(404).send({
|
return reply.code(404).send({
|
||||||
@@ -127,7 +127,7 @@ export class StationsController {
|
|||||||
reply: FastifyReply
|
reply: FastifyReply
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const userId = (request as any).user.sub;
|
const userId = request.userContext!.userId;
|
||||||
const { placeId } = request.params;
|
const { placeId } = request.params;
|
||||||
|
|
||||||
const result = await this.stationsService.updateSavedStation(placeId, userId, request.body);
|
const result = await this.stationsService.updateSavedStation(placeId, userId, request.body);
|
||||||
@@ -137,7 +137,7 @@ export class StationsController {
|
|||||||
logger.error('Error updating saved station', {
|
logger.error('Error updating saved station', {
|
||||||
error,
|
error,
|
||||||
placeId: request.params.placeId,
|
placeId: request.params.placeId,
|
||||||
userId: (request as any).user?.sub
|
userId: request.userContext?.userId
|
||||||
});
|
});
|
||||||
|
|
||||||
if (error.message.includes('not found')) {
|
if (error.message.includes('not found')) {
|
||||||
@@ -156,12 +156,12 @@ export class StationsController {
|
|||||||
|
|
||||||
async getSavedStations(request: FastifyRequest, reply: FastifyReply) {
|
async getSavedStations(request: FastifyRequest, reply: FastifyReply) {
|
||||||
try {
|
try {
|
||||||
const userId = (request as any).user.sub;
|
const userId = request.userContext!.userId;
|
||||||
const result = await this.stationsService.getUserSavedStations(userId);
|
const result = await this.stationsService.getUserSavedStations(userId);
|
||||||
|
|
||||||
return reply.code(200).send(result);
|
return reply.code(200).send(result);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error('Error getting saved stations', { error, userId: (request as any).user?.sub });
|
logger.error('Error getting saved stations', { error, userId: request.userContext?.userId });
|
||||||
return reply.code(500).send({
|
return reply.code(500).send({
|
||||||
error: 'Internal server error',
|
error: 'Internal server error',
|
||||||
message: 'Failed to get saved stations'
|
message: 'Failed to get saved stations'
|
||||||
@@ -171,14 +171,14 @@ export class StationsController {
|
|||||||
|
|
||||||
async removeSavedStation(request: FastifyRequest<{ Params: StationParams }>, reply: FastifyReply) {
|
async removeSavedStation(request: FastifyRequest<{ Params: StationParams }>, reply: FastifyReply) {
|
||||||
try {
|
try {
|
||||||
const userId = (request as any).user.sub;
|
const userId = request.userContext!.userId;
|
||||||
const { placeId } = request.params;
|
const { placeId } = request.params;
|
||||||
|
|
||||||
await this.stationsService.removeSavedStation(placeId, userId);
|
await this.stationsService.removeSavedStation(placeId, userId);
|
||||||
|
|
||||||
return reply.code(204).send();
|
return reply.code(204).send();
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error('Error removing saved station', { error, placeId: request.params.placeId, userId: (request as any).user?.sub });
|
logger.error('Error removing saved station', { error, placeId: request.params.placeId, userId: request.userContext?.userId });
|
||||||
|
|
||||||
if (error.message.includes('not found')) {
|
if (error.message.includes('not found')) {
|
||||||
return reply.code(404).send({
|
return reply.code(404).send({
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export class DonationsController {
|
|||||||
*/
|
*/
|
||||||
async createDonation(request: FastifyRequest, reply: FastifyReply) {
|
async createDonation(request: FastifyRequest, reply: FastifyReply) {
|
||||||
try {
|
try {
|
||||||
const userId = (request as any).user.sub;
|
const userId = request.userContext!.userId;
|
||||||
const { amount } = request.body as CreateDonationBody;
|
const { amount } = request.body as CreateDonationBody;
|
||||||
|
|
||||||
logger.info('Creating donation', { userId, amount });
|
logger.info('Creating donation', { userId, amount });
|
||||||
@@ -63,7 +63,7 @@ export class DonationsController {
|
|||||||
*/
|
*/
|
||||||
async getDonations(request: FastifyRequest, reply: FastifyReply) {
|
async getDonations(request: FastifyRequest, reply: FastifyReply) {
|
||||||
try {
|
try {
|
||||||
const userId = (request as any).user.sub;
|
const userId = request.userContext!.userId;
|
||||||
|
|
||||||
logger.info('Getting donations', { userId });
|
logger.info('Getting donations', { userId });
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export class SubscriptionsController {
|
|||||||
*/
|
*/
|
||||||
async getSubscription(request: FastifyRequest, reply: FastifyReply): Promise<void> {
|
async getSubscription(request: FastifyRequest, reply: FastifyReply): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const userId = (request as any).user.sub;
|
const userId = request.userContext!.userId;
|
||||||
|
|
||||||
const subscription = await this.service.getSubscription(userId);
|
const subscription = await this.service.getSubscription(userId);
|
||||||
|
|
||||||
@@ -39,7 +39,7 @@ export class SubscriptionsController {
|
|||||||
reply.status(200).send(subscription);
|
reply.status(200).send(subscription);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error('Failed to get subscription', {
|
logger.error('Failed to get subscription', {
|
||||||
userId: (request as any).user?.sub,
|
userId: request.userContext?.userId,
|
||||||
error: error.message,
|
error: error.message,
|
||||||
});
|
});
|
||||||
reply.status(500).send({
|
reply.status(500).send({
|
||||||
@@ -54,14 +54,14 @@ export class SubscriptionsController {
|
|||||||
*/
|
*/
|
||||||
async checkNeedsVehicleSelection(request: FastifyRequest, reply: FastifyReply): Promise<void> {
|
async checkNeedsVehicleSelection(request: FastifyRequest, reply: FastifyReply): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const userId = (request as any).user.sub;
|
const userId = request.userContext!.userId;
|
||||||
|
|
||||||
const result = await this.service.checkNeedsVehicleSelection(userId);
|
const result = await this.service.checkNeedsVehicleSelection(userId);
|
||||||
|
|
||||||
reply.status(200).send(result);
|
reply.status(200).send(result);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error('Failed to check needs vehicle selection', {
|
logger.error('Failed to check needs vehicle selection', {
|
||||||
userId: (request as any).user?.sub,
|
userId: request.userContext?.userId,
|
||||||
error: error.message,
|
error: error.message,
|
||||||
});
|
});
|
||||||
reply.status(500).send({
|
reply.status(500).send({
|
||||||
@@ -85,8 +85,8 @@ export class SubscriptionsController {
|
|||||||
reply: FastifyReply
|
reply: FastifyReply
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const userId = (request as any).user.sub;
|
const userId = request.userContext!.userId;
|
||||||
const email = (request as any).user.email;
|
const email = request.userContext!.email || '';
|
||||||
const { tier, billingCycle, paymentMethodId } = request.body;
|
const { tier, billingCycle, paymentMethodId } = request.body;
|
||||||
|
|
||||||
// Validate inputs
|
// Validate inputs
|
||||||
@@ -141,7 +141,7 @@ export class SubscriptionsController {
|
|||||||
reply.status(200).send(updatedSubscription);
|
reply.status(200).send(updatedSubscription);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error('Failed to create checkout', {
|
logger.error('Failed to create checkout', {
|
||||||
userId: (request as any).user?.sub,
|
userId: request.userContext?.userId,
|
||||||
error: error.message,
|
error: error.message,
|
||||||
});
|
});
|
||||||
reply.status(500).send({
|
reply.status(500).send({
|
||||||
@@ -156,14 +156,14 @@ export class SubscriptionsController {
|
|||||||
*/
|
*/
|
||||||
async cancelSubscription(request: FastifyRequest, reply: FastifyReply): Promise<void> {
|
async cancelSubscription(request: FastifyRequest, reply: FastifyReply): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const userId = (request as any).user.sub;
|
const userId = request.userContext!.userId;
|
||||||
|
|
||||||
const subscription = await this.service.cancelSubscription(userId);
|
const subscription = await this.service.cancelSubscription(userId);
|
||||||
|
|
||||||
reply.status(200).send(subscription);
|
reply.status(200).send(subscription);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error('Failed to cancel subscription', {
|
logger.error('Failed to cancel subscription', {
|
||||||
userId: (request as any).user?.sub,
|
userId: request.userContext?.userId,
|
||||||
error: error.message,
|
error: error.message,
|
||||||
});
|
});
|
||||||
reply.status(500).send({
|
reply.status(500).send({
|
||||||
@@ -178,14 +178,14 @@ export class SubscriptionsController {
|
|||||||
*/
|
*/
|
||||||
async reactivateSubscription(request: FastifyRequest, reply: FastifyReply): Promise<void> {
|
async reactivateSubscription(request: FastifyRequest, reply: FastifyReply): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const userId = (request as any).user.sub;
|
const userId = request.userContext!.userId;
|
||||||
|
|
||||||
const subscription = await this.service.reactivateSubscription(userId);
|
const subscription = await this.service.reactivateSubscription(userId);
|
||||||
|
|
||||||
reply.status(200).send(subscription);
|
reply.status(200).send(subscription);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error('Failed to reactivate subscription', {
|
logger.error('Failed to reactivate subscription', {
|
||||||
userId: (request as any).user?.sub,
|
userId: request.userContext?.userId,
|
||||||
error: error.message,
|
error: error.message,
|
||||||
});
|
});
|
||||||
reply.status(500).send({
|
reply.status(500).send({
|
||||||
@@ -207,8 +207,8 @@ export class SubscriptionsController {
|
|||||||
reply: FastifyReply
|
reply: FastifyReply
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const userId = (request as any).user.sub;
|
const userId = request.userContext!.userId;
|
||||||
const email = (request as any).user.email;
|
const email = request.userContext!.email || '';
|
||||||
const { paymentMethodId } = request.body;
|
const { paymentMethodId } = request.body;
|
||||||
|
|
||||||
// Validate input
|
// Validate input
|
||||||
@@ -228,7 +228,7 @@ export class SubscriptionsController {
|
|||||||
});
|
});
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error('Failed to update payment method', {
|
logger.error('Failed to update payment method', {
|
||||||
userId: (request as any).user?.sub,
|
userId: request.userContext?.userId,
|
||||||
error: error.message,
|
error: error.message,
|
||||||
});
|
});
|
||||||
reply.status(500).send({
|
reply.status(500).send({
|
||||||
@@ -243,14 +243,14 @@ export class SubscriptionsController {
|
|||||||
*/
|
*/
|
||||||
async getInvoices(request: FastifyRequest, reply: FastifyReply): Promise<void> {
|
async getInvoices(request: FastifyRequest, reply: FastifyReply): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const userId = (request as any).user.sub;
|
const userId = request.userContext!.userId;
|
||||||
|
|
||||||
const invoices = await this.service.getInvoices(userId);
|
const invoices = await this.service.getInvoices(userId);
|
||||||
|
|
||||||
reply.status(200).send(invoices);
|
reply.status(200).send(invoices);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error('Failed to get invoices', {
|
logger.error('Failed to get invoices', {
|
||||||
userId: (request as any).user?.sub,
|
userId: request.userContext?.userId,
|
||||||
error: error.message,
|
error: error.message,
|
||||||
});
|
});
|
||||||
reply.status(500).send({
|
reply.status(500).send({
|
||||||
@@ -273,7 +273,7 @@ export class SubscriptionsController {
|
|||||||
reply: FastifyReply
|
reply: FastifyReply
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const userId = (request as any).user.sub;
|
const userId = request.userContext!.userId;
|
||||||
const { targetTier, vehicleIdsToKeep } = request.body;
|
const { targetTier, vehicleIdsToKeep } = request.body;
|
||||||
|
|
||||||
// Validate inputs
|
// Validate inputs
|
||||||
@@ -311,7 +311,7 @@ export class SubscriptionsController {
|
|||||||
reply.status(200).send(updatedSubscription);
|
reply.status(200).send(updatedSubscription);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error('Failed to downgrade subscription', {
|
logger.error('Failed to downgrade subscription', {
|
||||||
userId: (request as any).user?.sub,
|
userId: request.userContext?.userId,
|
||||||
error: error.message,
|
error: error.message,
|
||||||
});
|
});
|
||||||
reply.status(500).send({
|
reply.status(500).send({
|
||||||
|
|||||||
@@ -767,7 +767,7 @@ export class SubscriptionsService {
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
// Get user profile for email and name
|
// Get user profile for email and name
|
||||||
const userProfile = await this.userProfileRepository.getByAuth0Sub(userId);
|
const userProfile = await this.userProfileRepository.getById(userId);
|
||||||
if (!userProfile) {
|
if (!userProfile) {
|
||||||
logger.warn('User profile not found for tier change notification', { userId });
|
logger.warn('User profile not found for tier change notification', { userId });
|
||||||
return;
|
return;
|
||||||
@@ -925,7 +925,7 @@ export class SubscriptionsService {
|
|||||||
|
|
||||||
// Sync tier to user_profiles table (within same transaction)
|
// Sync tier to user_profiles table (within same transaction)
|
||||||
await client.query(
|
await client.query(
|
||||||
'UPDATE user_profiles SET subscription_tier = $1 WHERE auth0_sub = $2',
|
'UPDATE user_profiles SET subscription_tier = $1 WHERE id = $2',
|
||||||
[newTier, userId]
|
[newTier, userId]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ export async function processGracePeriodExpirations(): Promise<GracePeriodResult
|
|||||||
up.notification_email,
|
up.notification_email,
|
||||||
up.display_name
|
up.display_name
|
||||||
FROM subscriptions s
|
FROM subscriptions s
|
||||||
LEFT JOIN user_profiles up ON s.user_id = up.auth0_sub
|
LEFT JOIN user_profiles up ON s.user_id = up.id
|
||||||
WHERE s.status = 'past_due'
|
WHERE s.status = 'past_due'
|
||||||
AND s.grace_period_end < NOW()
|
AND s.grace_period_end < NOW()
|
||||||
ORDER BY s.grace_period_end ASC
|
ORDER BY s.grace_period_end ASC
|
||||||
@@ -89,13 +89,13 @@ export async function processGracePeriodExpirations(): Promise<GracePeriodResult
|
|||||||
|
|
||||||
await client.query(updateQuery, [subscription.id]);
|
await client.query(updateQuery, [subscription.id]);
|
||||||
|
|
||||||
// Sync tier to user_profiles table (user_id is auth0_sub)
|
// Sync tier to user_profiles table
|
||||||
const syncQuery = `
|
const syncQuery = `
|
||||||
UPDATE user_profiles
|
UPDATE user_profiles
|
||||||
SET
|
SET
|
||||||
subscription_tier = 'free',
|
subscription_tier = 'free',
|
||||||
updated_at = NOW()
|
updated_at = NOW()
|
||||||
WHERE auth0_sub = $1
|
WHERE id = $1
|
||||||
`;
|
`;
|
||||||
|
|
||||||
await client.query(syncQuery, [subscription.user_id]);
|
await client.query(syncQuery, [subscription.user_id]);
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export class UserExportController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async downloadExport(request: FastifyRequest, reply: FastifyReply): Promise<void> {
|
async downloadExport(request: FastifyRequest, reply: FastifyReply): Promise<void> {
|
||||||
const userId = (request as any).user.sub;
|
const userId = request.userContext!.userId;
|
||||||
|
|
||||||
logger.info('User export requested', { userId });
|
logger.info('User export requested', { userId });
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export class UserImportController {
|
|||||||
* Uploads and imports user data archive
|
* Uploads and imports user data archive
|
||||||
*/
|
*/
|
||||||
async uploadAndImport(request: FastifyRequest, reply: FastifyReply): Promise<void> {
|
async uploadAndImport(request: FastifyRequest, reply: FastifyReply): Promise<void> {
|
||||||
const userId = request.user?.sub;
|
const userId = request.userContext?.userId;
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
return reply.code(401).send({ error: 'Unauthorized' });
|
return reply.code(401).send({ error: 'Unauthorized' });
|
||||||
}
|
}
|
||||||
@@ -139,7 +139,7 @@ export class UserImportController {
|
|||||||
* Generates preview of import data without executing import
|
* Generates preview of import data without executing import
|
||||||
*/
|
*/
|
||||||
async generatePreview(request: FastifyRequest, reply: FastifyReply): Promise<void> {
|
async generatePreview(request: FastifyRequest, reply: FastifyReply): Promise<void> {
|
||||||
const userId = request.user?.sub;
|
const userId = request.userContext?.userId;
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
return reply.code(401).send({ error: 'Unauthorized' });
|
return reply.code(401).send({ error: 'Unauthorized' });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export class UserPreferencesController {
|
|||||||
|
|
||||||
async getPreferences(request: FastifyRequest, reply: FastifyReply) {
|
async getPreferences(request: FastifyRequest, reply: FastifyReply) {
|
||||||
try {
|
try {
|
||||||
const userId = (request as any).user.sub;
|
const userId = request.userContext!.userId;
|
||||||
let preferences = await this.repository.findByUserId(userId);
|
let preferences = await this.repository.findByUserId(userId);
|
||||||
|
|
||||||
// Create default preferences if none exist
|
// Create default preferences if none exist
|
||||||
@@ -42,7 +42,7 @@ export class UserPreferencesController {
|
|||||||
updatedAt: preferences.updatedAt,
|
updatedAt: preferences.updatedAt,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error getting user preferences', { error, userId: (request as any).user?.sub });
|
logger.error('Error getting user preferences', { error, userId: request.userContext?.userId });
|
||||||
return reply.code(500).send({
|
return reply.code(500).send({
|
||||||
error: 'Internal server error',
|
error: 'Internal server error',
|
||||||
message: 'Failed to get preferences',
|
message: 'Failed to get preferences',
|
||||||
@@ -55,7 +55,7 @@ export class UserPreferencesController {
|
|||||||
reply: FastifyReply
|
reply: FastifyReply
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const userId = (request as any).user.sub;
|
const userId = request.userContext!.userId;
|
||||||
const { unitSystem, currencyCode, timeZone, darkMode } = request.body;
|
const { unitSystem, currencyCode, timeZone, darkMode } = request.body;
|
||||||
|
|
||||||
// Validate unitSystem if provided
|
// Validate unitSystem if provided
|
||||||
@@ -115,7 +115,7 @@ export class UserPreferencesController {
|
|||||||
updatedAt: preferences.updatedAt,
|
updatedAt: preferences.updatedAt,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error updating user preferences', { error, userId: (request as any).user?.sub });
|
logger.error('Error updating user preferences', { error, userId: request.userContext?.userId });
|
||||||
return reply.code(500).send({
|
return reply.code(500).send({
|
||||||
error: 'Internal server error',
|
error: 'Internal server error',
|
||||||
message: 'Failed to update preferences',
|
message: 'Failed to update preferences',
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export class VehiclesController {
|
|||||||
|
|
||||||
async getUserVehicles(request: FastifyRequest, reply: FastifyReply) {
|
async getUserVehicles(request: FastifyRequest, reply: FastifyReply) {
|
||||||
try {
|
try {
|
||||||
const userId = (request as any).user.sub;
|
const userId = request.userContext!.userId;
|
||||||
// Use tier-aware method to filter out locked vehicles after downgrade
|
// Use tier-aware method to filter out locked vehicles after downgrade
|
||||||
const vehiclesWithStatus = await this.vehiclesService.getUserVehiclesWithTierStatus(userId);
|
const vehiclesWithStatus = await this.vehiclesService.getUserVehiclesWithTierStatus(userId);
|
||||||
// Only return active vehicles (filter out locked ones)
|
// Only return active vehicles (filter out locked ones)
|
||||||
@@ -37,7 +37,7 @@ export class VehiclesController {
|
|||||||
|
|
||||||
return reply.code(200).send(vehicles);
|
return reply.code(200).send(vehicles);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error getting user vehicles', { error, userId: (request as any).user?.sub });
|
logger.error('Error getting user vehicles', { error, userId: request.userContext?.userId });
|
||||||
return reply.code(500).send({
|
return reply.code(500).send({
|
||||||
error: 'Internal server error',
|
error: 'Internal server error',
|
||||||
message: 'Failed to get vehicles'
|
message: 'Failed to get vehicles'
|
||||||
@@ -65,12 +65,12 @@ export class VehiclesController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const userId = (request as any).user.sub;
|
const userId = request.userContext!.userId;
|
||||||
const vehicle = await this.vehiclesService.createVehicle(request.body, userId);
|
const vehicle = await this.vehiclesService.createVehicle(request.body, userId);
|
||||||
|
|
||||||
return reply.code(201).send(vehicle);
|
return reply.code(201).send(vehicle);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error('Error creating vehicle', { error, userId: (request as any).user?.sub });
|
logger.error('Error creating vehicle', { error, userId: request.userContext?.userId });
|
||||||
|
|
||||||
if (error instanceof VehicleLimitExceededError) {
|
if (error instanceof VehicleLimitExceededError) {
|
||||||
return reply.code(403).send({
|
return reply.code(403).send({
|
||||||
@@ -110,7 +110,7 @@ export class VehiclesController {
|
|||||||
|
|
||||||
async getVehicle(request: FastifyRequest<{ Params: VehicleParams }>, reply: FastifyReply) {
|
async getVehicle(request: FastifyRequest<{ Params: VehicleParams }>, reply: FastifyReply) {
|
||||||
try {
|
try {
|
||||||
const userId = (request as any).user.sub;
|
const userId = request.userContext!.userId;
|
||||||
const { id } = request.params;
|
const { id } = request.params;
|
||||||
|
|
||||||
// Check tier status - block access to locked vehicles
|
// Check tier status - block access to locked vehicles
|
||||||
@@ -131,7 +131,7 @@ export class VehiclesController {
|
|||||||
|
|
||||||
return reply.code(200).send(vehicle);
|
return reply.code(200).send(vehicle);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error('Error getting vehicle', { error, vehicleId: request.params.id, userId: (request as any).user?.sub });
|
logger.error('Error getting vehicle', { error, vehicleId: request.params.id, userId: request.userContext?.userId });
|
||||||
|
|
||||||
if (error.message === 'Vehicle not found' || error.message === 'Unauthorized') {
|
if (error.message === 'Vehicle not found' || error.message === 'Unauthorized') {
|
||||||
return reply.code(404).send({
|
return reply.code(404).send({
|
||||||
@@ -149,14 +149,14 @@ export class VehiclesController {
|
|||||||
|
|
||||||
async updateVehicle(request: FastifyRequest<{ Params: VehicleParams; Body: UpdateVehicleBody }>, reply: FastifyReply) {
|
async updateVehicle(request: FastifyRequest<{ Params: VehicleParams; Body: UpdateVehicleBody }>, reply: FastifyReply) {
|
||||||
try {
|
try {
|
||||||
const userId = (request as any).user.sub;
|
const userId = request.userContext!.userId;
|
||||||
const { id } = request.params;
|
const { id } = request.params;
|
||||||
|
|
||||||
const vehicle = await this.vehiclesService.updateVehicle(id, request.body, userId);
|
const vehicle = await this.vehiclesService.updateVehicle(id, request.body, userId);
|
||||||
|
|
||||||
return reply.code(200).send(vehicle);
|
return reply.code(200).send(vehicle);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error('Error updating vehicle', { error, vehicleId: request.params.id, userId: (request as any).user?.sub });
|
logger.error('Error updating vehicle', { error, vehicleId: request.params.id, userId: request.userContext?.userId });
|
||||||
|
|
||||||
if (error.message === 'Vehicle not found' || error.message === 'Unauthorized') {
|
if (error.message === 'Vehicle not found' || error.message === 'Unauthorized') {
|
||||||
return reply.code(404).send({
|
return reply.code(404).send({
|
||||||
@@ -183,14 +183,14 @@ export class VehiclesController {
|
|||||||
|
|
||||||
async deleteVehicle(request: FastifyRequest<{ Params: VehicleParams }>, reply: FastifyReply) {
|
async deleteVehicle(request: FastifyRequest<{ Params: VehicleParams }>, reply: FastifyReply) {
|
||||||
try {
|
try {
|
||||||
const userId = (request as any).user.sub;
|
const userId = request.userContext!.userId;
|
||||||
const { id } = request.params;
|
const { id } = request.params;
|
||||||
|
|
||||||
await this.vehiclesService.deleteVehicle(id, userId);
|
await this.vehiclesService.deleteVehicle(id, userId);
|
||||||
|
|
||||||
return reply.code(204).send();
|
return reply.code(204).send();
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error('Error deleting vehicle', { error, vehicleId: request.params.id, userId: (request as any).user?.sub });
|
logger.error('Error deleting vehicle', { error, vehicleId: request.params.id, userId: request.userContext?.userId });
|
||||||
|
|
||||||
if (error.message === 'Vehicle not found' || error.message === 'Unauthorized') {
|
if (error.message === 'Vehicle not found' || error.message === 'Unauthorized') {
|
||||||
return reply.code(404).send({
|
return reply.code(404).send({
|
||||||
@@ -208,13 +208,13 @@ export class VehiclesController {
|
|||||||
|
|
||||||
async getTCO(request: FastifyRequest<{ Params: VehicleParams }>, reply: FastifyReply) {
|
async getTCO(request: FastifyRequest<{ Params: VehicleParams }>, reply: FastifyReply) {
|
||||||
try {
|
try {
|
||||||
const userId = (request as any).user.sub;
|
const userId = request.userContext!.userId;
|
||||||
const { id } = request.params;
|
const { id } = request.params;
|
||||||
|
|
||||||
const tco = await this.vehiclesService.getTCO(id, userId);
|
const tco = await this.vehiclesService.getTCO(id, userId);
|
||||||
return reply.code(200).send(tco);
|
return reply.code(200).send(tco);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error('Error getting vehicle TCO', { error, vehicleId: request.params.id, userId: (request as any).user?.sub });
|
logger.error('Error getting vehicle TCO', { error, vehicleId: request.params.id, userId: request.userContext?.userId });
|
||||||
|
|
||||||
if (error.statusCode === 404 || error.message === 'Vehicle not found') {
|
if (error.statusCode === 404 || error.message === 'Vehicle not found') {
|
||||||
return reply.code(404).send({
|
return reply.code(404).send({
|
||||||
@@ -383,7 +383,7 @@ export class VehiclesController {
|
|||||||
* Requires Pro or Enterprise tier
|
* Requires Pro or Enterprise tier
|
||||||
*/
|
*/
|
||||||
async decodeVin(request: FastifyRequest<{ Body: DecodeVinRequest }>, reply: FastifyReply) {
|
async decodeVin(request: FastifyRequest<{ Body: DecodeVinRequest }>, reply: FastifyReply) {
|
||||||
const userId = (request as any).user?.sub;
|
const userId = request.userContext?.userId;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { vin } = request.body;
|
const { vin } = request.body;
|
||||||
@@ -447,7 +447,7 @@ export class VehiclesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async uploadImage(request: FastifyRequest<{ Params: VehicleParams }>, reply: FastifyReply) {
|
async uploadImage(request: FastifyRequest<{ Params: VehicleParams }>, reply: FastifyReply) {
|
||||||
const userId = (request as any).user.sub;
|
const userId = request.userContext!.userId;
|
||||||
const vehicleId = request.params.id;
|
const vehicleId = request.params.id;
|
||||||
|
|
||||||
logger.info('Vehicle image upload requested', {
|
logger.info('Vehicle image upload requested', {
|
||||||
@@ -604,7 +604,7 @@ export class VehiclesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async downloadImage(request: FastifyRequest<{ Params: VehicleParams }>, reply: FastifyReply) {
|
async downloadImage(request: FastifyRequest<{ Params: VehicleParams }>, reply: FastifyReply) {
|
||||||
const userId = (request as any).user.sub;
|
const userId = request.userContext!.userId;
|
||||||
const vehicleId = request.params.id;
|
const vehicleId = request.params.id;
|
||||||
|
|
||||||
logger.info('Vehicle image download requested', {
|
logger.info('Vehicle image download requested', {
|
||||||
@@ -654,7 +654,7 @@ export class VehiclesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async deleteImage(request: FastifyRequest<{ Params: VehicleParams }>, reply: FastifyReply) {
|
async deleteImage(request: FastifyRequest<{ Params: VehicleParams }>, reply: FastifyReply) {
|
||||||
const userId = (request as any).user.sub;
|
const userId = request.userContext!.userId;
|
||||||
const vehicleId = request.params.id;
|
const vehicleId = request.params.id;
|
||||||
|
|
||||||
logger.info('Vehicle image delete requested', {
|
logger.info('Vehicle image delete requested', {
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ export class VehiclesService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get user's tier for limit enforcement
|
// Get user's tier for limit enforcement
|
||||||
const userProfile = await this.userProfileRepository.getByAuth0Sub(userId);
|
const userProfile = await this.userProfileRepository.getById(userId);
|
||||||
if (!userProfile) {
|
if (!userProfile) {
|
||||||
throw new Error('User profile not found');
|
throw new Error('User profile not found');
|
||||||
}
|
}
|
||||||
@@ -227,7 +227,7 @@ export class VehiclesService {
|
|||||||
*/
|
*/
|
||||||
async getUserVehiclesWithTierStatus(userId: string): Promise<Array<VehicleResponse & { tierStatus: 'active' | 'locked' }>> {
|
async getUserVehiclesWithTierStatus(userId: string): Promise<Array<VehicleResponse & { tierStatus: 'active' | 'locked' }>> {
|
||||||
// Get user's subscription tier
|
// Get user's subscription tier
|
||||||
const userProfile = await this.userProfileRepository.getByAuth0Sub(userId);
|
const userProfile = await this.userProfileRepository.getById(userId);
|
||||||
if (!userProfile) {
|
if (!userProfile) {
|
||||||
throw new Error('User profile not found');
|
throw new Error('User profile not found');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
Reference in New Issue
Block a user