fix(product-marketing): route image generation through unified subscription validation
Backend: - product_image_service.py: Replaced direct wavespeed_client.generate_image() with generate_image() from main_image_generation (unified entry point) - This ensures subscription pre-flight validation (_validate_image_operation) and usage tracking (_track_image_operation_usage) are enforced - Removed _generate_image_with_retry method and WaveSpeedClient dependency - Animation/video/avatar services already route through ImageStudioManager - no changes needed Frontend: - useProductMarketing.ts: Added formatError() helper for 402/429 detection across all 8 API operations - useCampaignCreator.ts: Added formatError() helper for 402/429 detection across all 13 API operations - All error messages now surface subscription limits with upgrade prompts
This commit is contained in:
@@ -13,7 +13,7 @@ from dataclasses import dataclass
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from services.wavespeed.client import WaveSpeedClient
|
from services.llm_providers.main_image_generation import generate_image
|
||||||
from utils.asset_tracker import save_asset_to_library
|
from utils.asset_tracker import save_asset_to_library
|
||||||
from services.database import SessionLocal
|
from services.database import SessionLocal
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
@@ -113,12 +113,7 @@ class ProductImageService:
|
|||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
"""Initialize Product Image Service."""
|
"""Initialize Product Image Service."""
|
||||||
try:
|
logger.info("[Product Image Service] Initialized")
|
||||||
self.wavespeed_client = WaveSpeedClient()
|
|
||||||
logger.info("[Product Image Service] Initialized")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"[Product Image Service] Failed to initialize WaveSpeed client: {str(e)}")
|
|
||||||
raise ProductImageServiceError(f"Failed to initialize service: {str(e)}") from e
|
|
||||||
|
|
||||||
def validate_request(self, request: ProductImageRequest) -> None:
|
def validate_request(self, request: ProductImageRequest) -> None:
|
||||||
"""
|
"""
|
||||||
@@ -260,77 +255,7 @@ class ProductImageService:
|
|||||||
|
|
||||||
return full_prompt
|
return full_prompt
|
||||||
|
|
||||||
def _generate_image_with_retry(
|
def generate_product_image(
|
||||||
self,
|
|
||||||
model: str,
|
|
||||||
prompt: str,
|
|
||||||
width: int,
|
|
||||||
height: int,
|
|
||||||
max_retries: int = 3,
|
|
||||||
retry_delay: float = 2.0
|
|
||||||
) -> bytes:
|
|
||||||
"""
|
|
||||||
Generate image with retry logic for transient failures.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
model: Model to use
|
|
||||||
prompt: Generation prompt
|
|
||||||
width: Image width
|
|
||||||
height: Image height
|
|
||||||
max_retries: Maximum number of retries
|
|
||||||
retry_delay: Delay between retries in seconds
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Generated image bytes
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ImageGenerationError: If generation fails after retries
|
|
||||||
"""
|
|
||||||
last_error = None
|
|
||||||
|
|
||||||
for attempt in range(max_retries):
|
|
||||||
try:
|
|
||||||
logger.info(f"[Product Image Service] Image generation attempt {attempt + 1}/{max_retries}")
|
|
||||||
|
|
||||||
image_bytes = self.wavespeed_client.generate_image(
|
|
||||||
model=model,
|
|
||||||
prompt=prompt,
|
|
||||||
width=width,
|
|
||||||
height=height,
|
|
||||||
enable_sync_mode=True,
|
|
||||||
timeout=120,
|
|
||||||
)
|
|
||||||
|
|
||||||
if not image_bytes:
|
|
||||||
raise ValueError("Image generation returned empty result")
|
|
||||||
|
|
||||||
if len(image_bytes) < 100: # Sanity check: image should be at least 100 bytes
|
|
||||||
raise ValueError(f"Generated image too small: {len(image_bytes)} bytes")
|
|
||||||
|
|
||||||
logger.info(f"[Product Image Service] ✅ Image generated successfully: {len(image_bytes)} bytes")
|
|
||||||
return image_bytes
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
last_error = e
|
|
||||||
error_msg = str(e)
|
|
||||||
logger.warning(f"[Product Image Service] Attempt {attempt + 1} failed: {error_msg}")
|
|
||||||
|
|
||||||
# Don't retry on validation errors or client errors (4xx)
|
|
||||||
if "4" in error_msg or "validation" in error_msg.lower() or "invalid" in error_msg.lower():
|
|
||||||
logger.error(f"[Product Image Service] Non-retryable error: {error_msg}")
|
|
||||||
raise ImageGenerationError(f"Image generation failed: {error_msg}") from e
|
|
||||||
|
|
||||||
# Retry on transient errors
|
|
||||||
if attempt < max_retries - 1:
|
|
||||||
logger.info(f"[Product Image Service] Retrying in {retry_delay} seconds...")
|
|
||||||
time.sleep(retry_delay)
|
|
||||||
retry_delay *= 1.5 # Exponential backoff
|
|
||||||
else:
|
|
||||||
logger.error(f"[Product Image Service] All retry attempts failed")
|
|
||||||
|
|
||||||
raise ImageGenerationError(f"Image generation failed after {max_retries} attempts: {str(last_error)}") from last_error
|
|
||||||
|
|
||||||
async def generate_product_image(
|
|
||||||
self,
|
self,
|
||||||
request: ProductImageRequest,
|
request: ProductImageRequest,
|
||||||
user_id: str,
|
user_id: str,
|
||||||
@@ -374,15 +299,18 @@ class ProductImageService:
|
|||||||
|
|
||||||
# Generate image using WaveSpeed with retry logic
|
# Generate image using WaveSpeed with retry logic
|
||||||
try:
|
try:
|
||||||
image_bytes = self._generate_image_with_retry(
|
result = generate_image(
|
||||||
model=model,
|
|
||||||
prompt=prompt,
|
prompt=prompt,
|
||||||
width=width,
|
options={
|
||||||
height=height,
|
"provider": "wavespeed",
|
||||||
max_retries=3,
|
"model": model,
|
||||||
retry_delay=2.0
|
"width": width,
|
||||||
|
"height": height,
|
||||||
|
},
|
||||||
|
user_id=user_id,
|
||||||
)
|
)
|
||||||
except ImageGenerationError as e:
|
image_bytes = result.image_bytes
|
||||||
|
except Exception as e:
|
||||||
logger.error(f"[Product Image Service] Image generation failed: {str(e)}")
|
logger.error(f"[Product Image Service] Image generation failed: {str(e)}")
|
||||||
generation_time = time.time() - start_time
|
generation_time = time.time() - start_time
|
||||||
return ProductImageResult(
|
return ProductImageResult(
|
||||||
|
|||||||
@@ -1,6 +1,15 @@
|
|||||||
import { useState, useCallback } from 'react';
|
import { useState, useCallback } from 'react';
|
||||||
import { aiApiClient } from '../api/client';
|
import { aiApiClient } from '../api/client';
|
||||||
|
|
||||||
|
const formatError = (err: any, fallback: string): string => {
|
||||||
|
if (err?.response?.status === 402 || err?.response?.status === 429) {
|
||||||
|
return 'Subscription limit reached. Upgrade your plan to continue using this feature.';
|
||||||
|
}
|
||||||
|
if (err?.response?.data?.detail) return String(err.response.data.detail);
|
||||||
|
if (err?.message) return String(err.message);
|
||||||
|
return fallback;
|
||||||
|
};
|
||||||
|
|
||||||
export interface CampaignCreateRequest {
|
export interface CampaignCreateRequest {
|
||||||
campaign_name: string;
|
campaign_name: string;
|
||||||
goal: string;
|
goal: string;
|
||||||
@@ -211,7 +220,7 @@ export const useCampaignCreator = () => {
|
|||||||
setBlueprint(response.data);
|
setBlueprint(response.data);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
const errorMessage = err.response?.data?.detail || err.message || 'Failed to create campaign blueprint';
|
const errorMessage = formatError(err, 'Failed to create campaign blueprint');
|
||||||
setError(errorMessage);
|
setError(errorMessage);
|
||||||
throw new Error(errorMessage);
|
throw new Error(errorMessage);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -236,7 +245,7 @@ export const useCampaignCreator = () => {
|
|||||||
setProposals(response.data);
|
setProposals(response.data);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
const errorMessage = err.response?.data?.detail || err.message || 'Failed to generate asset proposals';
|
const errorMessage = formatError(err, 'Failed to generate asset proposals');
|
||||||
setError(errorMessage);
|
setError(errorMessage);
|
||||||
throw new Error(errorMessage);
|
throw new Error(errorMessage);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -258,7 +267,7 @@ export const useCampaignCreator = () => {
|
|||||||
setGeneratedAsset(response.data);
|
setGeneratedAsset(response.data);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
const errorMessage = err.response?.data?.detail || err.message || 'Failed to generate asset';
|
const errorMessage = formatError(err, 'Failed to generate asset');
|
||||||
setError(errorMessage);
|
setError(errorMessage);
|
||||||
throw new Error(errorMessage);
|
throw new Error(errorMessage);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -276,7 +285,7 @@ export const useCampaignCreator = () => {
|
|||||||
setBrandDNA(response.data.brand_dna);
|
setBrandDNA(response.data.brand_dna);
|
||||||
return response.data.brand_dna;
|
return response.data.brand_dna;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
const errorMessage = err.response?.data?.detail || err.message || 'Failed to get brand DNA';
|
const errorMessage = formatError(err, 'Failed to get brand DNA');
|
||||||
setError(errorMessage);
|
setError(errorMessage);
|
||||||
throw new Error(errorMessage);
|
throw new Error(errorMessage);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -294,7 +303,7 @@ export const useCampaignCreator = () => {
|
|||||||
);
|
);
|
||||||
return response.data.brand_dna;
|
return response.data.brand_dna;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
const errorMessage = err.response?.data?.detail || err.message || 'Failed to get channel brand DNA';
|
const errorMessage = formatError(err, 'Failed to get channel brand DNA');
|
||||||
setError(errorMessage);
|
setError(errorMessage);
|
||||||
throw new Error(errorMessage);
|
throw new Error(errorMessage);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -315,7 +324,7 @@ export const useCampaignCreator = () => {
|
|||||||
setChannelPack(response.data);
|
setChannelPack(response.data);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
const errorMessage = err.response?.data?.detail || err.message || 'Failed to get channel pack';
|
const errorMessage = formatError(err, 'Failed to get channel pack');
|
||||||
setError(errorMessage);
|
setError(errorMessage);
|
||||||
throw new Error(errorMessage);
|
throw new Error(errorMessage);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -337,7 +346,7 @@ export const useCampaignCreator = () => {
|
|||||||
setAuditResult(response.data);
|
setAuditResult(response.data);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
const errorMessage = err.response?.data?.detail || err.message || 'Failed to audit asset';
|
const errorMessage = formatError(err, 'Failed to audit asset');
|
||||||
setError(errorMessage);
|
setError(errorMessage);
|
||||||
throw new Error(errorMessage);
|
throw new Error(errorMessage);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -356,7 +365,7 @@ export const useCampaignCreator = () => {
|
|||||||
setCampaigns(response.data.campaigns);
|
setCampaigns(response.data.campaigns);
|
||||||
return response.data.campaigns;
|
return response.data.campaigns;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
const errorMessage = err.response?.data?.detail || err.message || 'Failed to list campaigns';
|
const errorMessage = formatError(err, 'Failed to list campaigns');
|
||||||
setError(errorMessage);
|
setError(errorMessage);
|
||||||
throw new Error(errorMessage);
|
throw new Error(errorMessage);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -370,7 +379,7 @@ export const useCampaignCreator = () => {
|
|||||||
const response = await aiApiClient.get<CampaignBlueprint>(`/api/campaign-creator/campaigns/${campaignId}`);
|
const response = await aiApiClient.get<CampaignBlueprint>(`/api/campaign-creator/campaigns/${campaignId}`);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
const errorMessage = err.response?.data?.detail || err.message || 'Failed to get campaign';
|
const errorMessage = formatError(err, 'Failed to get campaign');
|
||||||
setError(errorMessage);
|
setError(errorMessage);
|
||||||
throw new Error(errorMessage);
|
throw new Error(errorMessage);
|
||||||
}
|
}
|
||||||
@@ -382,7 +391,7 @@ export const useCampaignCreator = () => {
|
|||||||
const response = await aiApiClient.get<AssetProposalsResponse>(`/api/campaign-creator/campaigns/${campaignId}/proposals`);
|
const response = await aiApiClient.get<AssetProposalsResponse>(`/api/campaign-creator/campaigns/${campaignId}/proposals`);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
const errorMessage = err.response?.data?.detail || err.message || 'Failed to get proposals';
|
const errorMessage = formatError(err, 'Failed to get proposals');
|
||||||
setError(errorMessage);
|
setError(errorMessage);
|
||||||
throw new Error(errorMessage);
|
throw new Error(errorMessage);
|
||||||
}
|
}
|
||||||
@@ -400,7 +409,7 @@ export const useCampaignCreator = () => {
|
|||||||
setPreflightResult(response.data);
|
setPreflightResult(response.data);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
const errorMessage = err.response?.data?.detail || err.message || 'Failed to validate campaign pre-flight';
|
const errorMessage = formatError(err, 'Failed to validate campaign pre-flight');
|
||||||
setError(errorMessage);
|
setError(errorMessage);
|
||||||
const errorResult: PreflightValidationResult = {
|
const errorResult: PreflightValidationResult = {
|
||||||
can_proceed: false,
|
can_proceed: false,
|
||||||
@@ -452,7 +461,7 @@ export const useCampaignCreator = () => {
|
|||||||
const response = await aiApiClient.get(`/api/product-marketing/personalization/defaults/${formType}`);
|
const response = await aiApiClient.get(`/api/product-marketing/personalization/defaults/${formType}`);
|
||||||
return response.data.defaults;
|
return response.data.defaults;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
const errorMessage = err.response?.data?.detail || err.message || 'Failed to get personalized defaults';
|
const errorMessage = formatError(err, 'Failed to get personalized defaults');
|
||||||
setError(errorMessage);
|
setError(errorMessage);
|
||||||
throw new Error(errorMessage);
|
throw new Error(errorMessage);
|
||||||
}
|
}
|
||||||
@@ -468,7 +477,7 @@ export const useCampaignCreator = () => {
|
|||||||
setRecommendations(response.data.recommendations);
|
setRecommendations(response.data.recommendations);
|
||||||
return response.data.recommendations;
|
return response.data.recommendations;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
const errorMessage = err.response?.data?.detail || err.message || 'Failed to get recommendations';
|
const errorMessage = formatError(err, 'Failed to get recommendations');
|
||||||
setError(errorMessage);
|
setError(errorMessage);
|
||||||
throw new Error(errorMessage);
|
throw new Error(errorMessage);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -1,6 +1,15 @@
|
|||||||
import { useState, useCallback } from 'react';
|
import { useState, useCallback } from 'react';
|
||||||
import { aiApiClient } from '../api/client';
|
import { aiApiClient } from '../api/client';
|
||||||
|
|
||||||
|
const formatError = (err: any, fallback: string): string => {
|
||||||
|
if (err?.response?.status === 402 || err?.response?.status === 429) {
|
||||||
|
return 'Subscription limit reached. Upgrade your plan to continue using this feature.';
|
||||||
|
}
|
||||||
|
if (err?.response?.data?.detail) return String(err.response.data.detail);
|
||||||
|
if (err?.message) return String(err.message);
|
||||||
|
return fallback;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* useProductMarketing Hook
|
* useProductMarketing Hook
|
||||||
*
|
*
|
||||||
@@ -37,7 +46,7 @@ export const useProductMarketing = () => {
|
|||||||
setGeneratedProductImage(response.data);
|
setGeneratedProductImage(response.data);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
const errorMessage = err.response?.data?.detail || err.message || 'Failed to generate product image';
|
const errorMessage = formatError(err, 'Failed to generate product image');
|
||||||
setProductImageError(errorMessage);
|
setProductImageError(errorMessage);
|
||||||
throw new Error(errorMessage);
|
throw new Error(errorMessage);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -70,7 +79,7 @@ export const useProductMarketing = () => {
|
|||||||
setGeneratedAnimation(response.data);
|
setGeneratedAnimation(response.data);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
const errorMessage = err.response?.data?.detail || err.message || 'Failed to generate product animation';
|
const errorMessage = formatError(err, 'Failed to generate product animation');
|
||||||
setAnimationError(errorMessage);
|
setAnimationError(errorMessage);
|
||||||
throw new Error(errorMessage);
|
throw new Error(errorMessage);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -102,7 +111,7 @@ export const useProductMarketing = () => {
|
|||||||
setGeneratedVideo(response.data);
|
setGeneratedVideo(response.data);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
const errorMessage = err.response?.data?.detail || err.message || 'Failed to generate product video';
|
const errorMessage = formatError(err, 'Failed to generate product video');
|
||||||
setVideoError(errorMessage);
|
setVideoError(errorMessage);
|
||||||
throw new Error(errorMessage);
|
throw new Error(errorMessage);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -137,7 +146,7 @@ export const useProductMarketing = () => {
|
|||||||
setGeneratedAvatar(response.data);
|
setGeneratedAvatar(response.data);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
const errorMessage = err.response?.data?.detail || err.message || 'Failed to generate product avatar';
|
const errorMessage = formatError(err, 'Failed to generate product avatar');
|
||||||
setAvatarError(errorMessage);
|
setAvatarError(errorMessage);
|
||||||
throw new Error(errorMessage);
|
throw new Error(errorMessage);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -174,7 +183,7 @@ export const useProductMarketing = () => {
|
|||||||
setInferredConfig(response.data.configuration);
|
setInferredConfig(response.data.configuration);
|
||||||
return response.data.configuration;
|
return response.data.configuration;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
const errorMessage = err.response?.data?.detail || err.message || 'Failed to infer requirements';
|
const errorMessage = formatError(err, 'Failed to infer requirements');
|
||||||
setInferenceError(errorMessage);
|
setInferenceError(errorMessage);
|
||||||
throw new Error(errorMessage);
|
throw new Error(errorMessage);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -194,8 +203,8 @@ export const useProductMarketing = () => {
|
|||||||
setBrandDNA(response.data.brand_dna);
|
setBrandDNA(response.data.brand_dna);
|
||||||
return response.data.brand_dna;
|
return response.data.brand_dna;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
const errorMessage = err.response?.data?.detail || err.message || 'Failed to get brand DNA';
|
const errorMessage = formatError(err, 'Failed to get brand DNA');
|
||||||
setError(errorMessage);
|
setError(errorMessage);
|
||||||
throw new Error(errorMessage);
|
throw new Error(errorMessage);
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoadingBrandDNA(false);
|
setIsLoadingBrandDNA(false);
|
||||||
@@ -210,8 +219,8 @@ export const useProductMarketing = () => {
|
|||||||
setUserPreferences(response.data.preferences);
|
setUserPreferences(response.data.preferences);
|
||||||
return response.data.preferences;
|
return response.data.preferences;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
const errorMessage = err.response?.data?.detail || err.message || 'Failed to get user preferences';
|
const errorMessage = formatError(err, 'Failed to get user preferences');
|
||||||
setError(errorMessage);
|
setError(errorMessage);
|
||||||
throw new Error(errorMessage);
|
throw new Error(errorMessage);
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoadingPreferences(false);
|
setIsLoadingPreferences(false);
|
||||||
@@ -225,7 +234,7 @@ export const useProductMarketing = () => {
|
|||||||
const response = await aiApiClient.get(`/api/product-marketing/personalization/defaults/${formType}`);
|
const response = await aiApiClient.get(`/api/product-marketing/personalization/defaults/${formType}`);
|
||||||
return response.data.defaults;
|
return response.data.defaults;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
const errorMessage = err.response?.data?.detail || err.message || 'Failed to get personalized defaults';
|
const errorMessage = formatError(err, 'Failed to get personalized defaults');
|
||||||
setError(errorMessage);
|
setError(errorMessage);
|
||||||
throw new Error(errorMessage);
|
throw new Error(errorMessage);
|
||||||
}
|
}
|
||||||
@@ -241,8 +250,8 @@ export const useProductMarketing = () => {
|
|||||||
setRecommendations(response.data.recommendations);
|
setRecommendations(response.data.recommendations);
|
||||||
return response.data.recommendations;
|
return response.data.recommendations;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
const errorMessage = err.response?.data?.detail || err.message || 'Failed to get recommendations';
|
const errorMessage = formatError(err, 'Failed to get recommendations');
|
||||||
setError(errorMessage);
|
setError(errorMessage);
|
||||||
throw new Error(errorMessage);
|
throw new Error(errorMessage);
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoadingRecommendations(false);
|
setIsLoadingRecommendations(false);
|
||||||
|
|||||||
Reference in New Issue
Block a user