mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-22 18:11:13 -05:00
Fix: Implement Phase 1 and 2 for Account & Profile tab
This commit is contained in:
@@ -23,16 +23,18 @@ import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
|||||||
import { toast as sonnerToast } from 'sonner';
|
import { toast as sonnerToast } from 'sonner';
|
||||||
import { AccountDeletionDialog } from './AccountDeletionDialog';
|
import { AccountDeletionDialog } from './AccountDeletionDialog';
|
||||||
import { DeletionStatusBanner } from './DeletionStatusBanner';
|
import { DeletionStatusBanner } from './DeletionStatusBanner';
|
||||||
import { usernameSchema, displayNameSchema, bioSchema } from '@/lib/validation';
|
import { usernameSchema, displayNameSchema, bioSchema, personalLocationSchema, preferredPronounsSchema } from '@/lib/validation';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
import { AccountDeletionRequest } from '@/types/database';
|
||||||
|
|
||||||
const profileSchema = z.object({
|
const profileSchema = z.object({
|
||||||
username: usernameSchema,
|
username: usernameSchema,
|
||||||
display_name: displayNameSchema,
|
display_name: displayNameSchema,
|
||||||
bio: bioSchema,
|
bio: bioSchema,
|
||||||
preferred_pronouns: z.string().max(20).optional(),
|
preferred_pronouns: preferredPronounsSchema,
|
||||||
show_pronouns: z.boolean(),
|
show_pronouns: z.boolean(),
|
||||||
preferred_language: z.string()
|
preferred_language: z.string(),
|
||||||
|
personal_location: personalLocationSchema
|
||||||
});
|
});
|
||||||
|
|
||||||
type ProfileFormData = z.infer<typeof profileSchema>;
|
type ProfileFormData = z.infer<typeof profileSchema>;
|
||||||
@@ -50,7 +52,7 @@ export function AccountProfileTab() {
|
|||||||
const [avatarUrl, setAvatarUrl] = useState<string>(profile?.avatar_url || '');
|
const [avatarUrl, setAvatarUrl] = useState<string>(profile?.avatar_url || '');
|
||||||
const [avatarImageId, setAvatarImageId] = useState<string>(profile?.avatar_image_id || '');
|
const [avatarImageId, setAvatarImageId] = useState<string>(profile?.avatar_image_id || '');
|
||||||
const [showDeletionDialog, setShowDeletionDialog] = useState(false);
|
const [showDeletionDialog, setShowDeletionDialog] = useState(false);
|
||||||
const [deletionRequest, setDeletionRequest] = useState<any>(null);
|
const [deletionRequest, setDeletionRequest] = useState<AccountDeletionRequest | null>(null);
|
||||||
|
|
||||||
const form = useForm<ProfileFormData>({
|
const form = useForm<ProfileFormData>({
|
||||||
resolver: zodResolver(profileSchema),
|
resolver: zodResolver(profileSchema),
|
||||||
@@ -60,7 +62,8 @@ export function AccountProfileTab() {
|
|||||||
bio: profile?.bio || '',
|
bio: profile?.bio || '',
|
||||||
preferred_pronouns: profile?.preferred_pronouns || '',
|
preferred_pronouns: profile?.preferred_pronouns || '',
|
||||||
show_pronouns: profile?.show_pronouns || false,
|
show_pronouns: profile?.show_pronouns || false,
|
||||||
preferred_language: profile?.preferred_language || 'en'
|
preferred_language: profile?.preferred_language || 'en',
|
||||||
|
personal_location: profile?.personal_location || ''
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -89,29 +92,28 @@ export function AccountProfileTab() {
|
|||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const usernameChanged = profile?.username !== data.username;
|
// Use the update_profile RPC function with server-side validation
|
||||||
|
const { data: result, error } = await supabase.rpc('update_profile', {
|
||||||
const { error } = await supabase
|
p_username: data.username,
|
||||||
.from('profiles')
|
p_display_name: data.display_name || null,
|
||||||
.update({
|
p_bio: data.bio || null,
|
||||||
username: data.username,
|
p_preferred_pronouns: data.preferred_pronouns || null,
|
||||||
display_name: data.display_name || null,
|
p_show_pronouns: data.show_pronouns,
|
||||||
bio: data.bio || null,
|
p_preferred_language: data.preferred_language,
|
||||||
preferred_pronouns: data.preferred_pronouns || null,
|
p_personal_location: data.personal_location || null
|
||||||
show_pronouns: data.show_pronouns,
|
});
|
||||||
preferred_language: data.preferred_language,
|
|
||||||
updated_at: new Date().toISOString()
|
|
||||||
})
|
|
||||||
.eq('user_id', user.id);
|
|
||||||
|
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
|
|
||||||
|
// Type the RPC result
|
||||||
|
const rpcResult = result as unknown as { success: boolean; username_changed: boolean; changes_count: number };
|
||||||
|
|
||||||
// Update Novu subscriber if username changed
|
// Update Novu subscriber if username changed
|
||||||
if (usernameChanged && notificationService.isEnabled()) {
|
if (rpcResult?.username_changed && notificationService.isEnabled()) {
|
||||||
await notificationService.updateSubscriber({
|
await notificationService.updateSubscriber({
|
||||||
subscriberId: user.id,
|
subscriberId: user.id,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
firstName: data.username, // Send username as firstName to Novu
|
firstName: data.username,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,19 +139,17 @@ export function AccountProfileTab() {
|
|||||||
const newAvatarUrl = urls[0];
|
const newAvatarUrl = urls[0];
|
||||||
const newImageId = imageId || '';
|
const newImageId = imageId || '';
|
||||||
|
|
||||||
// Update local state immediately
|
// Update local state immediately for optimistic UI
|
||||||
setAvatarUrl(newAvatarUrl);
|
setAvatarUrl(newAvatarUrl);
|
||||||
setAvatarImageId(newImageId);
|
setAvatarImageId(newImageId);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { error } = await supabase
|
// Use update_profile RPC for avatar updates
|
||||||
.from('profiles')
|
const { error } = await supabase.rpc('update_profile', {
|
||||||
.update({
|
p_username: profile?.username || '',
|
||||||
avatar_url: newAvatarUrl,
|
p_avatar_url: newAvatarUrl,
|
||||||
avatar_image_id: newImageId,
|
p_avatar_image_id: newImageId
|
||||||
updated_at: new Date().toISOString()
|
});
|
||||||
})
|
|
||||||
.eq('user_id', user.id);
|
|
||||||
|
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
|
|
||||||
@@ -245,7 +245,7 @@ export function AccountProfileTab() {
|
|||||||
setDeletionRequest(null);
|
setDeletionRequest(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const isDeactivated = (profile as any)?.deactivated || false;
|
const isDeactivated = profile?.deactivated || false;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
@@ -337,6 +337,11 @@ export function AccountProfileTab() {
|
|||||||
{...form.register('preferred_pronouns')}
|
{...form.register('preferred_pronouns')}
|
||||||
placeholder="e.g., they/them, she/her, he/him"
|
placeholder="e.g., they/them, she/her, he/him"
|
||||||
/>
|
/>
|
||||||
|
{form.formState.errors.preferred_pronouns && (
|
||||||
|
<p className="text-sm text-destructive">
|
||||||
|
{form.formState.errors.preferred_pronouns.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -359,6 +364,20 @@ export function AccountProfileTab() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="personal_location">Personal Location</Label>
|
||||||
|
<Input
|
||||||
|
id="personal_location"
|
||||||
|
{...form.register('personal_location')}
|
||||||
|
placeholder="e.g., Los Angeles, CA"
|
||||||
|
/>
|
||||||
|
{form.formState.errors.personal_location && (
|
||||||
|
<p className="text-sm text-destructive">
|
||||||
|
{form.formState.errors.personal_location.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<Button type="submit" disabled={loading || isDeactivated}>
|
<Button type="submit" disabled={loading || isDeactivated}>
|
||||||
{loading ? 'Saving...' : 'Save Changes'}
|
{loading ? 'Saving...' : 'Save Changes'}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export function useProfile(userId: string | undefined) {
|
|||||||
|
|
||||||
if (!data) return null;
|
if (!data) return null;
|
||||||
|
|
||||||
// Type assertion since we know the structure from the RPC function
|
// Type the JSONB response properly
|
||||||
const profileData = data as unknown as Profile;
|
const profileData = data as unknown as Profile;
|
||||||
|
|
||||||
// Fetch location separately if location_id is present and visible
|
// Fetch location separately if location_id is present and visible
|
||||||
|
|||||||
@@ -3080,6 +3080,20 @@ export type Database = {
|
|||||||
Args: { target_park_id: string }
|
Args: { target_park_id: string }
|
||||||
Returns: undefined
|
Returns: undefined
|
||||||
}
|
}
|
||||||
|
update_profile: {
|
||||||
|
Args: {
|
||||||
|
p_avatar_image_id?: string
|
||||||
|
p_avatar_url?: string
|
||||||
|
p_bio?: string
|
||||||
|
p_display_name?: string
|
||||||
|
p_personal_location?: string
|
||||||
|
p_preferred_language?: string
|
||||||
|
p_preferred_pronouns?: string
|
||||||
|
p_show_pronouns?: boolean
|
||||||
|
p_username: string
|
||||||
|
}
|
||||||
|
Returns: Json
|
||||||
|
}
|
||||||
update_ride_ratings: {
|
update_ride_ratings: {
|
||||||
Args: { target_ride_id: string }
|
Args: { target_ride_id: string }
|
||||||
Returns: undefined
|
Returns: undefined
|
||||||
|
|||||||
@@ -8,11 +8,11 @@ interface EmailValidationResult {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Validates an email address against disposable email domains
|
* Validates an email address against disposable email domains
|
||||||
* Uses the validate-email edge function to check the backend blocklist
|
* Uses the validate-email-backend edge function for server-side validation
|
||||||
*/
|
*/
|
||||||
export async function validateEmailNotDisposable(email: string): Promise<EmailValidationResult> {
|
export async function validateEmailNotDisposable(email: string): Promise<EmailValidationResult> {
|
||||||
try {
|
try {
|
||||||
const { data, error } = await supabase.functions.invoke('validate-email', {
|
const { data, error } = await supabase.functions.invoke('validate-email-backend', {
|
||||||
body: { email }
|
body: { email }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -92,6 +92,13 @@ export const personalLocationSchema = z.string()
|
|||||||
)
|
)
|
||||||
.optional();
|
.optional();
|
||||||
|
|
||||||
|
// Preferred pronouns validation
|
||||||
|
export const preferredPronounsSchema = z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.max(20, { message: "Pronouns must be less than 20 characters" })
|
||||||
|
.optional();
|
||||||
|
|
||||||
export const profileEditSchema = z.object({
|
export const profileEditSchema = z.object({
|
||||||
username: usernameSchema,
|
username: usernameSchema,
|
||||||
display_name: displayNameSchema,
|
display_name: displayNameSchema,
|
||||||
|
|||||||
@@ -186,6 +186,26 @@ export interface Profile {
|
|||||||
park_count: number;
|
park_count: number;
|
||||||
review_count: number;
|
review_count: number;
|
||||||
reputation_score: number;
|
reputation_score: number;
|
||||||
|
banned: boolean;
|
||||||
|
deactivated: boolean;
|
||||||
|
deactivated_at?: string;
|
||||||
|
deactivation_reason?: string;
|
||||||
|
oauth_provider?: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AccountDeletionRequest {
|
||||||
|
id: string;
|
||||||
|
user_id: string;
|
||||||
|
requested_at: string;
|
||||||
|
scheduled_deletion_at: string;
|
||||||
|
confirmation_code: string;
|
||||||
|
confirmation_code_sent_at?: string;
|
||||||
|
status: 'pending' | 'confirmed' | 'cancelled' | 'completed';
|
||||||
|
cancelled_at?: string;
|
||||||
|
completed_at?: string;
|
||||||
|
cancellation_reason?: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,3 +41,6 @@ verify_jwt = true
|
|||||||
|
|
||||||
[functions.process-oauth-profile]
|
[functions.process-oauth-profile]
|
||||||
verify_jwt = true
|
verify_jwt = true
|
||||||
|
|
||||||
|
[functions.validate-email-backend]
|
||||||
|
verify_jwt = false
|
||||||
94
supabase/functions/validate-email-backend/index.ts
Normal file
94
supabase/functions/validate-email-backend/index.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import { serve } from "https://deno.land/std@0.168.0/http/server.ts";
|
||||||
|
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.39.3';
|
||||||
|
|
||||||
|
const corsHeaders = {
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Common disposable email domains (subset for performance)
|
||||||
|
const DISPOSABLE_DOMAINS = new Set([
|
||||||
|
'tempmail.com', 'guerrillamail.com', '10minutemail.com', 'mailinator.com',
|
||||||
|
'throwaway.email', 'temp-mail.org', 'fakeinbox.com', 'maildrop.cc',
|
||||||
|
'yopmail.com', 'sharklasers.com', 'guerrillamailblock.com'
|
||||||
|
]);
|
||||||
|
|
||||||
|
interface EmailValidationResult {
|
||||||
|
valid: boolean;
|
||||||
|
reason?: string;
|
||||||
|
suggestions?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateEmailFormat(email: string): EmailValidationResult {
|
||||||
|
// Basic format validation
|
||||||
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
if (!emailRegex.test(email)) {
|
||||||
|
return { valid: false, reason: 'Invalid email format' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract domain
|
||||||
|
const domain = email.split('@')[1].toLowerCase();
|
||||||
|
|
||||||
|
// Check against disposable domains
|
||||||
|
if (DISPOSABLE_DOMAINS.has(domain)) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
reason: 'Disposable email addresses are not allowed. Please use a permanent email address.',
|
||||||
|
suggestions: ['gmail.com', 'outlook.com', 'yahoo.com', 'protonmail.com']
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for suspicious patterns
|
||||||
|
if (domain.includes('temp') || domain.includes('disposable') || domain.includes('trash')) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
reason: 'This email domain appears to be temporary. Please use a permanent email address.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
serve(async (req) => {
|
||||||
|
// Handle CORS preflight requests
|
||||||
|
if (req.method === 'OPTIONS') {
|
||||||
|
return new Response(null, { headers: corsHeaders });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { email } = await req.json();
|
||||||
|
|
||||||
|
if (!email || typeof email !== 'string') {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ valid: false, reason: 'Email is required' }),
|
||||||
|
{
|
||||||
|
status: 400,
|
||||||
|
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate email
|
||||||
|
const result = validateEmailFormat(email.toLowerCase().trim());
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify(result),
|
||||||
|
{
|
||||||
|
status: 200,
|
||||||
|
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Email validation error:', error);
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
valid: false,
|
||||||
|
reason: 'Failed to validate email. Please try again.'
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 500,
|
||||||
|
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
-- Phase 2: Create update_profile RPC function with validation and audit logging
|
||||||
|
CREATE OR REPLACE FUNCTION public.update_profile(
|
||||||
|
p_username text,
|
||||||
|
p_display_name text DEFAULT NULL,
|
||||||
|
p_bio text DEFAULT NULL,
|
||||||
|
p_preferred_pronouns text DEFAULT NULL,
|
||||||
|
p_show_pronouns boolean DEFAULT false,
|
||||||
|
p_preferred_language text DEFAULT 'en',
|
||||||
|
p_personal_location text DEFAULT NULL,
|
||||||
|
p_avatar_url text DEFAULT NULL,
|
||||||
|
p_avatar_image_id text DEFAULT NULL
|
||||||
|
)
|
||||||
|
RETURNS jsonb
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path = public
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_user_id uuid;
|
||||||
|
v_old_profile record;
|
||||||
|
v_changes jsonb := '[]'::jsonb;
|
||||||
|
v_change jsonb;
|
||||||
|
BEGIN
|
||||||
|
-- Get authenticated user ID
|
||||||
|
v_user_id := auth.uid();
|
||||||
|
|
||||||
|
IF v_user_id IS NULL THEN
|
||||||
|
RAISE EXCEPTION 'Not authenticated';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Get current profile for comparison
|
||||||
|
SELECT * INTO v_old_profile
|
||||||
|
FROM public.profiles
|
||||||
|
WHERE user_id = v_user_id;
|
||||||
|
|
||||||
|
IF v_old_profile IS NULL THEN
|
||||||
|
RAISE EXCEPTION 'Profile not found';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Check if account is deactivated
|
||||||
|
IF v_old_profile.deactivated THEN
|
||||||
|
RAISE EXCEPTION 'Cannot update deactivated account';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Validate username length and format (3-20 chars, alphanumeric, underscore, hyphen)
|
||||||
|
IF p_username IS NOT NULL THEN
|
||||||
|
IF LENGTH(p_username) < 3 OR LENGTH(p_username) > 20 THEN
|
||||||
|
RAISE EXCEPTION 'Username must be between 3 and 20 characters';
|
||||||
|
END IF;
|
||||||
|
IF p_username !~ '^[a-z0-9_-]+$' THEN
|
||||||
|
RAISE EXCEPTION 'Username can only contain lowercase letters, numbers, underscores, and hyphens';
|
||||||
|
END IF;
|
||||||
|
-- Check for consecutive special characters
|
||||||
|
IF p_username ~ '__' OR p_username ~ '--' OR p_username ~ '_-' OR p_username ~ '-_' THEN
|
||||||
|
RAISE EXCEPTION 'Username cannot contain consecutive special characters';
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Validate display_name length (max 50 chars)
|
||||||
|
IF p_display_name IS NOT NULL AND LENGTH(p_display_name) > 50 THEN
|
||||||
|
RAISE EXCEPTION 'Display name must be 50 characters or less';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Validate bio length (max 500 chars)
|
||||||
|
IF p_bio IS NOT NULL AND LENGTH(p_bio) > 500 THEN
|
||||||
|
RAISE EXCEPTION 'Bio must be 500 characters or less';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Validate personal_location (max 100 chars, no special chars like <>)
|
||||||
|
IF p_personal_location IS NOT NULL THEN
|
||||||
|
IF LENGTH(p_personal_location) > 100 THEN
|
||||||
|
RAISE EXCEPTION 'Personal location must be 100 characters or less';
|
||||||
|
END IF;
|
||||||
|
IF p_personal_location ~ '[<>]' THEN
|
||||||
|
RAISE EXCEPTION 'Personal location contains invalid characters';
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Track changes for audit log
|
||||||
|
IF v_old_profile.username IS DISTINCT FROM p_username THEN
|
||||||
|
v_changes := v_changes || jsonb_build_array(
|
||||||
|
jsonb_build_object('field', 'username', 'old', v_old_profile.username, 'new', p_username)
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF v_old_profile.display_name IS DISTINCT FROM p_display_name THEN
|
||||||
|
v_changes := v_changes || jsonb_build_array(
|
||||||
|
jsonb_build_object('field', 'display_name', 'old', v_old_profile.display_name, 'new', p_display_name)
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF v_old_profile.bio IS DISTINCT FROM p_bio THEN
|
||||||
|
v_changes := v_changes || jsonb_build_array(
|
||||||
|
jsonb_build_object('field', 'bio', 'old', v_old_profile.bio, 'new', p_bio)
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Update profile (updated_at is handled by trigger)
|
||||||
|
UPDATE public.profiles
|
||||||
|
SET
|
||||||
|
username = COALESCE(p_username, username),
|
||||||
|
display_name = p_display_name,
|
||||||
|
bio = p_bio,
|
||||||
|
preferred_pronouns = p_preferred_pronouns,
|
||||||
|
show_pronouns = p_show_pronouns,
|
||||||
|
preferred_language = COALESCE(p_preferred_language, preferred_language),
|
||||||
|
personal_location = p_personal_location,
|
||||||
|
avatar_url = COALESCE(p_avatar_url, avatar_url),
|
||||||
|
avatar_image_id = COALESCE(p_avatar_image_id, avatar_image_id)
|
||||||
|
WHERE user_id = v_user_id;
|
||||||
|
|
||||||
|
-- Log significant changes
|
||||||
|
IF jsonb_array_length(v_changes) > 0 THEN
|
||||||
|
INSERT INTO public.admin_audit_log (admin_user_id, target_user_id, action, details)
|
||||||
|
VALUES (v_user_id, v_user_id, 'profile_updated', jsonb_build_object('changes', v_changes));
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN jsonb_build_object(
|
||||||
|
'success', true,
|
||||||
|
'username_changed', v_old_profile.username IS DISTINCT FROM p_username,
|
||||||
|
'changes_count', jsonb_array_length(v_changes)
|
||||||
|
);
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
GRANT EXECUTE ON FUNCTION public.update_profile TO authenticated;
|
||||||
Reference in New Issue
Block a user