feat(F-105): completed feature
This commit is contained in:
@@ -31,14 +31,25 @@ export interface UsersRoutesDeps {
|
||||
const uuidParamSchema = z.object({ id: z.uuid() });
|
||||
const addressIdParamSchema = z.object({ id: z.uuid(), addressId: z.uuid() });
|
||||
|
||||
const preferencesSchema = z
|
||||
.object({
|
||||
orderUpdates: z.boolean().optional(),
|
||||
newsletter: z.boolean().optional(),
|
||||
promos: z.boolean().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const profilePatchSchema = z
|
||||
.object({
|
||||
displayName: z.string().min(1).max(200).optional(),
|
||||
phone: z.string().min(1).max(50).optional(),
|
||||
preferences: preferencesSchema.optional(),
|
||||
})
|
||||
.refine((value) => value.displayName !== undefined || value.phone !== undefined, {
|
||||
message: 'At least one of displayName or phone is required',
|
||||
});
|
||||
.refine(
|
||||
(value) =>
|
||||
value.displayName !== undefined || value.phone !== undefined || value.preferences !== undefined,
|
||||
{ message: 'At least one of displayName, phone or preferences is required' },
|
||||
);
|
||||
|
||||
const newAddressSchema = z.object({
|
||||
label: z.string().max(100).optional().nullable(),
|
||||
@@ -115,7 +126,8 @@ export async function registerUsersRoutes(
|
||||
if (!customer) {
|
||||
throw new AppError(404, 'NOT_FOUND', 'Customer not found');
|
||||
}
|
||||
return reply.send(serializeCustomer(customer));
|
||||
const profile = await profiles.findByUserId(id);
|
||||
return reply.send({ ...serializeCustomer(customer), preferences: profile?.preferences ?? undefined });
|
||||
});
|
||||
|
||||
const patchUserSchema: FastifySchema = {
|
||||
@@ -240,6 +252,7 @@ function serializeProfile(profile: Profile) {
|
||||
userId: profile.userId,
|
||||
displayName: profile.displayName,
|
||||
phone: profile.phone,
|
||||
preferences: profile.preferences,
|
||||
createdAt: profile.createdAt.toISOString(),
|
||||
updatedAt: profile.updatedAt.toISOString(),
|
||||
};
|
||||
|
||||
@@ -6,14 +6,32 @@ export interface Profile {
|
||||
userId: string;
|
||||
displayName: string | null;
|
||||
phone: string | null;
|
||||
preferences: UserPreferences;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
/** Preferencias gestionadas por el cliente desde su cuenta. */
|
||||
export interface UserPreferences {
|
||||
/** Aviso por email de cambios de estado de pedidos. */
|
||||
orderUpdates: boolean;
|
||||
/** Boletín de novedades. */
|
||||
newsletter: boolean;
|
||||
/** Ofertas y promociones. */
|
||||
promos: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_PREFERENCES: UserPreferences = {
|
||||
orderUpdates: true,
|
||||
newsletter: false,
|
||||
promos: false,
|
||||
};
|
||||
|
||||
/** Fields a profile update may set. Undefined = leave unchanged. */
|
||||
export interface ProfilePatch {
|
||||
displayName?: string | null;
|
||||
phone?: string | null;
|
||||
preferences?: Partial<UserPreferences>;
|
||||
}
|
||||
|
||||
/** Joined identity_users + users_profiles row for admin customer listing. */
|
||||
|
||||
@@ -10,12 +10,15 @@ import type {
|
||||
CustomerSummary,
|
||||
Profile,
|
||||
ProfilePatch,
|
||||
UserPreferences,
|
||||
} from '../domain/profile.js';
|
||||
import { DEFAULT_PREFERENCES } from '../domain/profile.js';
|
||||
|
||||
interface ProfileRow {
|
||||
user_id: string;
|
||||
display_name: string | null;
|
||||
phone: string | null;
|
||||
preferences: Partial<UserPreferences> | null;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
@@ -34,7 +37,7 @@ export class PgProfileRepository implements ProfileRepository {
|
||||
|
||||
async findByUserId(userId: string): Promise<Profile | undefined> {
|
||||
const result = await this.pool.query<ProfileRow>(
|
||||
`SELECT user_id, display_name, phone, created_at, updated_at
|
||||
`SELECT user_id, display_name, phone, preferences, created_at, updated_at
|
||||
FROM users_profiles WHERE user_id = $1`,
|
||||
[userId],
|
||||
);
|
||||
@@ -59,6 +62,11 @@ export class PgProfileRepository implements ProfileRepository {
|
||||
values.push(patch.phone);
|
||||
setClauses.push(`phone = $${values.length}`);
|
||||
}
|
||||
if (patch.preferences !== undefined) {
|
||||
// Merge parcial: solo cambian las claves enviadas.
|
||||
values.push(JSON.stringify(patch.preferences));
|
||||
setClauses.push(`preferences = COALESCE(preferences, '{}'::jsonb) || $${values.length}::jsonb`);
|
||||
}
|
||||
if (setClauses.length > 0) {
|
||||
values.push(userId);
|
||||
await this.pool.query(
|
||||
@@ -77,7 +85,7 @@ export class PgProfileRepository implements ProfileRepository {
|
||||
|
||||
async list(): Promise<Profile[]> {
|
||||
const result = await this.pool.query<ProfileRow>(
|
||||
`SELECT user_id, display_name, phone, created_at, updated_at
|
||||
`SELECT user_id, display_name, phone, preferences, created_at, updated_at
|
||||
FROM users_profiles ORDER BY created_at`,
|
||||
);
|
||||
return result.rows.map(toProfile);
|
||||
@@ -130,6 +138,7 @@ function toProfile(row: ProfileRow): Profile {
|
||||
userId: row.user_id,
|
||||
displayName: row.display_name,
|
||||
phone: row.phone,
|
||||
preferences: { ...DEFAULT_PREFERENCES, ...(row.preferences ?? {}) },
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user