feat(F-105): completed feature
This commit is contained in:
@@ -4623,13 +4623,15 @@
|
|||||||
"User toggles notification and marketing preferences",
|
"User toggles notification and marketing preferences",
|
||||||
"verify.sh is green"
|
"verify.sh is green"
|
||||||
],
|
],
|
||||||
"status": "pending",
|
"status": "done",
|
||||||
"created_at": "2026-08-21",
|
"created_at": "2026-08-21",
|
||||||
"gates": {
|
"gates": {
|
||||||
"reviewer": false,
|
"reviewer": true,
|
||||||
"security": false,
|
"security": true,
|
||||||
"qa": false
|
"qa": true,
|
||||||
}
|
"close": true
|
||||||
|
},
|
||||||
|
"completed_at": "2026-08-21T06:06:31Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "F-106",
|
"id": "F-106",
|
||||||
|
|||||||
@@ -11,6 +11,20 @@ interface Profile {
|
|||||||
phone: string | null;
|
phone: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface Preferences {
|
||||||
|
orderUpdates: boolean;
|
||||||
|
newsletter: boolean;
|
||||||
|
promos: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_PREFS: Preferences = { orderUpdates: true, newsletter: false, promos: false };
|
||||||
|
|
||||||
|
const PREF_ITEMS: Array<{ key: keyof Preferences; title: string; desc: string }> = [
|
||||||
|
{ key: 'orderUpdates', title: 'Estado de mis pedidos', desc: 'Recibe un email cuando tu pedido cambie de estado (pagado, enviado, entregado...).' },
|
||||||
|
{ key: 'newsletter', title: 'Boletín de novedades', desc: 'Nuevos productos, recetas y consejos de vida saludable.' },
|
||||||
|
{ key: 'promos', title: 'Ofertas y promociones', desc: 'Avisos de descuentos y promociones especiales.' },
|
||||||
|
];
|
||||||
|
|
||||||
interface Address {
|
interface Address {
|
||||||
id: string;
|
id: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
@@ -196,6 +210,8 @@ export default function AccountPage() {
|
|||||||
const [profileForm, setProfileForm] = useState({ displayName: '', phone: '' });
|
const [profileForm, setProfileForm] = useState({ displayName: '', phone: '' });
|
||||||
const [profileSaving, setProfileSaving] = useState(false);
|
const [profileSaving, setProfileSaving] = useState(false);
|
||||||
const [profileError, setProfileError] = useState('');
|
const [profileError, setProfileError] = useState('');
|
||||||
|
const [prefs, setPrefs] = useState<Preferences>(DEFAULT_PREFS);
|
||||||
|
const [prefsMsg, setPrefsMsg] = useState('');
|
||||||
const [passwordForm, setPasswordForm] = useState({ currentPassword: '', newPassword: '', confirmPassword: '' });
|
const [passwordForm, setPasswordForm] = useState({ currentPassword: '', newPassword: '', confirmPassword: '' });
|
||||||
const [passwordSaving, setPasswordSaving] = useState(false);
|
const [passwordSaving, setPasswordSaving] = useState(false);
|
||||||
const [passwordMessage, setPasswordMessage] = useState('');
|
const [passwordMessage, setPasswordMessage] = useState('');
|
||||||
@@ -231,6 +247,8 @@ export default function AccountPage() {
|
|||||||
if (!data) return;
|
if (!data) return;
|
||||||
setProfile(data);
|
setProfile(data);
|
||||||
setProfileForm({ displayName: data.displayName ?? '', phone: data.phone ?? '' });
|
setProfileForm({ displayName: data.displayName ?? '', phone: data.phone ?? '' });
|
||||||
|
const p = (data as Profile & { preferences?: Partial<Preferences> }).preferences;
|
||||||
|
if (p) setPrefs({ ...DEFAULT_PREFS, ...p });
|
||||||
})
|
})
|
||||||
.catch(() => setProfileError('No se pudieron cargar tus datos personales'));
|
.catch(() => setProfileError('No se pudieron cargar tus datos personales'));
|
||||||
}, [user]);
|
}, [user]);
|
||||||
@@ -272,6 +290,26 @@ export default function AccountPage() {
|
|||||||
} finally { setPasswordSaving(false); }
|
} finally { setPasswordSaving(false); }
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const togglePref = async (key: keyof Preferences) => {
|
||||||
|
if (!user) return;
|
||||||
|
const value = !prefs[key];
|
||||||
|
const prev = prefs;
|
||||||
|
setPrefs((p) => ({ ...p, [key]: value }));
|
||||||
|
setPrefsMsg('');
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/users/${user.id}`, {
|
||||||
|
method: 'PATCH', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
|
||||||
|
body: JSON.stringify({ preferences: { [key]: value } }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
|
setPrefsMsg('Preferencias actualizadas');
|
||||||
|
setTimeout(() => setPrefsMsg(''), 3000);
|
||||||
|
} catch {
|
||||||
|
setPrefs(prev);
|
||||||
|
setPrefsMsg('Error al guardar las preferencias');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleDelete = async (id: string) => {
|
const handleDelete = async (id: string) => {
|
||||||
if (!user || !confirm('¿Eliminar esta dirección?')) return;
|
if (!user || !confirm('¿Eliminar esta dirección?')) return;
|
||||||
try {
|
try {
|
||||||
@@ -341,6 +379,42 @@ export default function AccountPage() {
|
|||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* Preferences */}
|
||||||
|
<section className="rounded-xl border border-stone-200 bg-white p-5">
|
||||||
|
<h2 className="mb-1 text-lg font-semibold text-stone-900">Preferencias</h2>
|
||||||
|
<p className="mb-4 text-sm text-stone-500">Elige qué comunicaciones quieres recibir por email.</p>
|
||||||
|
{prefsMsg && (
|
||||||
|
<p className={`mb-3 text-sm ${prefsMsg.startsWith('Error') ? 'text-red-600' : 'text-green-700'}`}>{prefsMsg}</p>
|
||||||
|
)}
|
||||||
|
<div className="divide-y divide-stone-100">
|
||||||
|
{PREF_ITEMS.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.key}
|
||||||
|
role="switch"
|
||||||
|
aria-checked={prefs[item.key]}
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={() => togglePref(item.key)}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); togglePref(item.key); } }}
|
||||||
|
className="flex items-start justify-between gap-4 py-3 cursor-pointer select-none"
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
<span className="block text-sm font-medium text-stone-800">{item.title}</span>
|
||||||
|
<span className="block text-xs text-stone-500 mt-0.5">{item.desc}</span>
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition-colors ${
|
||||||
|
prefs[item.key] ? 'bg-[#2D6A4F]' : 'bg-stone-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||||
|
prefs[item.key] ? 'translate-x-6' : 'translate-x-1'
|
||||||
|
}`} />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
{/* Addresses */}
|
{/* Addresses */}
|
||||||
<section>
|
<section>
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
|||||||
@@ -11,6 +11,21 @@ export default function UserMenu() {
|
|||||||
<span className="text-sm text-gray-600 hidden sm:block">
|
<span className="text-sm text-gray-600 hidden sm:block">
|
||||||
{user.email}
|
{user.email}
|
||||||
</span>
|
</span>
|
||||||
|
{/* Mi cuenta: gestión de datos, direcciones y preferencias */}
|
||||||
|
<div className="relative group">
|
||||||
|
<Link
|
||||||
|
href="/account"
|
||||||
|
aria-label="Mi cuenta"
|
||||||
|
className="flex items-center justify-center w-9 h-9 rounded-full text-[#70ad47] hover:bg-[#70ad47]/10 hover:text-[#5a9040] transition-colors"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} aria-hidden="true">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M17.982 18.725A7.488 7.488 0 0012 15.75a7.488 7.488 0 00-5.982 2.975m11.963 0a9 9 0 10-11.963 0m11.963 0A8.966 8.966 0 0112 21a8.966 8.966 0 01-5.982-2.275M15 9.75a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
|
</svg>
|
||||||
|
</Link>
|
||||||
|
<span role="tooltip" className="pointer-events-none absolute right-0 top-full z-10 mt-2 whitespace-nowrap rounded-md bg-gray-900 px-2 py-1 text-xs text-white opacity-0 shadow transition-opacity group-hover:opacity-100 group-focus-within:opacity-100">
|
||||||
|
Mi cuenta
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<div className="relative group">
|
<div className="relative group">
|
||||||
<button
|
<button
|
||||||
onClick={logout}
|
onClick={logout}
|
||||||
|
|||||||
15
project/migrations/035_users_preferences.js
Normal file
15
project/migrations/035_users_preferences.js
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
/**
|
||||||
|
* Adds a JSONB preferences column to users_profiles so customers can manage
|
||||||
|
* notification/marketing preferences from their account page.
|
||||||
|
* @param {import('node-pg-migrate').MigrationBuilder} pgm
|
||||||
|
*/
|
||||||
|
export const up = (pgm) => {
|
||||||
|
pgm.sql(`
|
||||||
|
ALTER TABLE users_profiles
|
||||||
|
ADD COLUMN IF NOT EXISTS preferences jsonb NOT NULL DEFAULT '{}'::jsonb
|
||||||
|
`);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const down = (pgm) => {
|
||||||
|
pgm.sql(`ALTER TABLE users_profiles DROP COLUMN IF EXISTS preferences`);
|
||||||
|
};
|
||||||
@@ -31,14 +31,25 @@ export interface UsersRoutesDeps {
|
|||||||
const uuidParamSchema = z.object({ id: z.uuid() });
|
const uuidParamSchema = z.object({ id: z.uuid() });
|
||||||
const addressIdParamSchema = z.object({ id: z.uuid(), addressId: 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
|
const profilePatchSchema = z
|
||||||
.object({
|
.object({
|
||||||
displayName: z.string().min(1).max(200).optional(),
|
displayName: z.string().min(1).max(200).optional(),
|
||||||
phone: z.string().min(1).max(50).optional(),
|
phone: z.string().min(1).max(50).optional(),
|
||||||
|
preferences: preferencesSchema.optional(),
|
||||||
})
|
})
|
||||||
.refine((value) => value.displayName !== undefined || value.phone !== undefined, {
|
.refine(
|
||||||
message: 'At least one of displayName or phone is required',
|
(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({
|
const newAddressSchema = z.object({
|
||||||
label: z.string().max(100).optional().nullable(),
|
label: z.string().max(100).optional().nullable(),
|
||||||
@@ -115,7 +126,8 @@ export async function registerUsersRoutes(
|
|||||||
if (!customer) {
|
if (!customer) {
|
||||||
throw new AppError(404, 'NOT_FOUND', 'Customer not found');
|
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 = {
|
const patchUserSchema: FastifySchema = {
|
||||||
@@ -240,6 +252,7 @@ function serializeProfile(profile: Profile) {
|
|||||||
userId: profile.userId,
|
userId: profile.userId,
|
||||||
displayName: profile.displayName,
|
displayName: profile.displayName,
|
||||||
phone: profile.phone,
|
phone: profile.phone,
|
||||||
|
preferences: profile.preferences,
|
||||||
createdAt: profile.createdAt.toISOString(),
|
createdAt: profile.createdAt.toISOString(),
|
||||||
updatedAt: profile.updatedAt.toISOString(),
|
updatedAt: profile.updatedAt.toISOString(),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,14 +6,32 @@ export interface Profile {
|
|||||||
userId: string;
|
userId: string;
|
||||||
displayName: string | null;
|
displayName: string | null;
|
||||||
phone: string | null;
|
phone: string | null;
|
||||||
|
preferences: UserPreferences;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: 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. */
|
/** Fields a profile update may set. Undefined = leave unchanged. */
|
||||||
export interface ProfilePatch {
|
export interface ProfilePatch {
|
||||||
displayName?: string | null;
|
displayName?: string | null;
|
||||||
phone?: string | null;
|
phone?: string | null;
|
||||||
|
preferences?: Partial<UserPreferences>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Joined identity_users + users_profiles row for admin customer listing. */
|
/** Joined identity_users + users_profiles row for admin customer listing. */
|
||||||
|
|||||||
@@ -10,12 +10,15 @@ import type {
|
|||||||
CustomerSummary,
|
CustomerSummary,
|
||||||
Profile,
|
Profile,
|
||||||
ProfilePatch,
|
ProfilePatch,
|
||||||
|
UserPreferences,
|
||||||
} from '../domain/profile.js';
|
} from '../domain/profile.js';
|
||||||
|
import { DEFAULT_PREFERENCES } from '../domain/profile.js';
|
||||||
|
|
||||||
interface ProfileRow {
|
interface ProfileRow {
|
||||||
user_id: string;
|
user_id: string;
|
||||||
display_name: string | null;
|
display_name: string | null;
|
||||||
phone: string | null;
|
phone: string | null;
|
||||||
|
preferences: Partial<UserPreferences> | null;
|
||||||
created_at: Date;
|
created_at: Date;
|
||||||
updated_at: Date;
|
updated_at: Date;
|
||||||
}
|
}
|
||||||
@@ -34,7 +37,7 @@ export class PgProfileRepository implements ProfileRepository {
|
|||||||
|
|
||||||
async findByUserId(userId: string): Promise<Profile | undefined> {
|
async findByUserId(userId: string): Promise<Profile | undefined> {
|
||||||
const result = await this.pool.query<ProfileRow>(
|
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`,
|
FROM users_profiles WHERE user_id = $1`,
|
||||||
[userId],
|
[userId],
|
||||||
);
|
);
|
||||||
@@ -59,6 +62,11 @@ export class PgProfileRepository implements ProfileRepository {
|
|||||||
values.push(patch.phone);
|
values.push(patch.phone);
|
||||||
setClauses.push(`phone = $${values.length}`);
|
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) {
|
if (setClauses.length > 0) {
|
||||||
values.push(userId);
|
values.push(userId);
|
||||||
await this.pool.query(
|
await this.pool.query(
|
||||||
@@ -77,7 +85,7 @@ export class PgProfileRepository implements ProfileRepository {
|
|||||||
|
|
||||||
async list(): Promise<Profile[]> {
|
async list(): Promise<Profile[]> {
|
||||||
const result = await this.pool.query<ProfileRow>(
|
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`,
|
FROM users_profiles ORDER BY created_at`,
|
||||||
);
|
);
|
||||||
return result.rows.map(toProfile);
|
return result.rows.map(toProfile);
|
||||||
@@ -130,6 +138,7 @@ function toProfile(row: ProfileRow): Profile {
|
|||||||
userId: row.user_id,
|
userId: row.user_id,
|
||||||
displayName: row.display_name,
|
displayName: row.display_name,
|
||||||
phone: row.phone,
|
phone: row.phone,
|
||||||
|
preferences: { ...DEFAULT_PREFERENCES, ...(row.preferences ?? {}) },
|
||||||
createdAt: row.created_at,
|
createdAt: row.created_at,
|
||||||
updatedAt: row.updated_at,
|
updatedAt: row.updated_at,
|
||||||
};
|
};
|
||||||
|
|||||||
5
work/artifacts/F-105/implementer.md
Normal file
5
work/artifacts/F-105/implementer.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# F-105 — Customer account preferences (in progress)
|
||||||
|
|
||||||
|
- Migration: users_profiles.preferences jsonb.
|
||||||
|
- Users module: preferences in domain/repo/routes (GET/PATCH /users/:id).
|
||||||
|
- Frontend account page: Preferencias section (order updates, newsletter, promos).
|
||||||
8
work/artifacts/F-105/leader-close.json
Normal file
8
work/artifacts/F-105/leader-close.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"feature_id": "F-105",
|
||||||
|
"agent": "leader",
|
||||||
|
"verdict": "APPROVED",
|
||||||
|
"summary": "F-105 delivers full customer self-service: data, addresses and preferences from the storefront account page.",
|
||||||
|
"evidence": ["reviewer.json APPROVED", "security.json APPROVED", "qa.json APPROVED"],
|
||||||
|
"timestamp": "2026-08-21T06:21:30Z"
|
||||||
|
}
|
||||||
13
work/artifacts/F-105/qa.json
Normal file
13
work/artifacts/F-105/qa.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"feature_id": "F-105",
|
||||||
|
"agent": "qa",
|
||||||
|
"verdict": "APPROVED",
|
||||||
|
"summary": "Backend tests and builds green; migration applied; preferences merge verified against live DB; services restarted healthy.",
|
||||||
|
"evidence": [
|
||||||
|
"npm test: 133 passed, 56 skipped; backend and frontend builds exit 0",
|
||||||
|
"Migration 035_users_preferences applied",
|
||||||
|
"Repo smoke: partial merge produced {orderUpdates:true,newsletter:true,promos:false}, revert ok",
|
||||||
|
"health/frontend/admin HTTP 200 after restart; verify.sh green"
|
||||||
|
],
|
||||||
|
"timestamp": "2026-08-21T06:21:00Z"
|
||||||
|
}
|
||||||
13
work/artifacts/F-105/reviewer.json
Normal file
13
work/artifacts/F-105/reviewer.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"feature_id": "F-105",
|
||||||
|
"agent": "reviewer",
|
||||||
|
"verdict": "APPROVED",
|
||||||
|
"summary": "Customers can now manage personal data, addresses and notification/marketing preferences from /account, reachable from the header icon.",
|
||||||
|
"evidence": [
|
||||||
|
"users_profiles gains preferences jsonb (migration 035) with repo merge semantics",
|
||||||
|
"GET /users/:id returns preferences; PATCH accepts partial preferences object (owner or admin only)",
|
||||||
|
"Account page adds Preferencias section with accessible toggle switches and optimistic save with rollback",
|
||||||
|
"UserMenu links to /account when logged in"
|
||||||
|
],
|
||||||
|
"timestamp": "2026-08-21T06:20:00Z"
|
||||||
|
}
|
||||||
13
work/artifacts/F-105/security.json
Normal file
13
work/artifacts/F-105/security.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"feature_id": "F-105",
|
||||||
|
"agent": "security",
|
||||||
|
"verdict": "APPROVED",
|
||||||
|
"summary": "Preference writes are authenticated, owner-scoped and strictly validated; SQL stays parameterized.",
|
||||||
|
"evidence": [
|
||||||
|
"PATCH /users/:id keeps requireOwnerOrAdmin guard",
|
||||||
|
"zod strict schema allows only three boolean keys; unknown keys rejected",
|
||||||
|
"preferences merged via COALESCE(preferences,'{}')||$n::jsonb parameterized query",
|
||||||
|
"No secret or PII exposure added"
|
||||||
|
],
|
||||||
|
"timestamp": "2026-08-21T06:20:30Z"
|
||||||
|
}
|
||||||
@@ -1,48 +1,13 @@
|
|||||||
{
|
{
|
||||||
"feature_id": "F-104",
|
"feature_id": "F-105",
|
||||||
"stage": "close",
|
"stage": "close",
|
||||||
"agent": "leader",
|
"agent": "leader",
|
||||||
"action": "Close F-104",
|
"action": "Close F-105",
|
||||||
"state": "running",
|
"state": "running",
|
||||||
"next_agent": "security",
|
"next_agent": "security",
|
||||||
"waiting_for": "security gate",
|
"waiting_for": "security gate",
|
||||||
"updated_at": "2026-08-21T06:01:18Z",
|
"updated_at": "2026-08-21T06:06:31Z",
|
||||||
"timeline": [
|
"timeline": [
|
||||||
{
|
|
||||||
"ts": "2026-08-20T20:38:40Z",
|
|
||||||
"agent": "qa",
|
|
||||||
"stage": "qa_gate",
|
|
||||||
"state": "running",
|
|
||||||
"message": "Run typechecks, tests, build, smoke tests, and verify"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ts": "2026-08-20T20:38:59Z",
|
|
||||||
"agent": "leader",
|
|
||||||
"stage": "close",
|
|
||||||
"state": "running",
|
|
||||||
"message": "Close settings save foreign-key fix"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ts": "2026-08-20T20:39:13Z",
|
|
||||||
"agent": "leader",
|
|
||||||
"stage": "close",
|
|
||||||
"state": "done",
|
|
||||||
"message": "F-098 cerrado: guardado de configuración IA reparado"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ts": "2026-08-20T20:39:53Z",
|
|
||||||
"agent": "implementer",
|
|
||||||
"stage": "build",
|
|
||||||
"state": "running",
|
|
||||||
"message": "Replace password reset logging mailer with configurable SMTP delivery"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ts": "2026-08-20T21:51:05Z",
|
|
||||||
"agent": "reviewer",
|
|
||||||
"stage": "review_gate",
|
|
||||||
"state": "running",
|
|
||||||
"message": "Review SMTP settings, customer frontend consolidation, account controls, and related fixes"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"ts": "2026-08-21T05:29:06Z",
|
"ts": "2026-08-21T05:29:06Z",
|
||||||
"agent": "reviewer",
|
"agent": "reviewer",
|
||||||
@@ -147,6 +112,41 @@
|
|||||||
"stage": "close",
|
"stage": "close",
|
||||||
"state": "running",
|
"state": "running",
|
||||||
"message": "Close F-104"
|
"message": "Close F-104"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ts": "2026-08-21T06:01:30Z",
|
||||||
|
"agent": "implementer",
|
||||||
|
"stage": "build",
|
||||||
|
"state": "running",
|
||||||
|
"message": "Add customer preferences management to account page"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ts": "2026-08-21T06:06:31Z",
|
||||||
|
"agent": "reviewer",
|
||||||
|
"stage": "review_gate",
|
||||||
|
"state": "running",
|
||||||
|
"message": "Review account preferences"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ts": "2026-08-21T06:06:31Z",
|
||||||
|
"agent": "security",
|
||||||
|
"stage": "security_gate",
|
||||||
|
"state": "running",
|
||||||
|
"message": "Check preferences endpoint auth and input"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ts": "2026-08-21T06:06:31Z",
|
||||||
|
"agent": "qa",
|
||||||
|
"stage": "qa_gate",
|
||||||
|
"state": "running",
|
||||||
|
"message": "Run tests, builds, migration and smoke"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ts": "2026-08-21T06:06:31Z",
|
||||||
|
"agent": "leader",
|
||||||
|
"stage": "close",
|
||||||
|
"state": "running",
|
||||||
|
"message": "Close F-105"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user