From f8d2fb8eb71fdc9e50ef46daf6946cf7e0961b0f Mon Sep 17 00:00:00 2001 From: Daniel Arantes Loverde Date: Thu, 16 Apr 2026 14:46:38 -0300 Subject: [PATCH 1/3] feat(feature-control): add standalone BFF with bootstrap, exposure telemetry and tests --- feature-control-bff/README.md | 89 +++++++ feature-control-bff/core.mjs | 333 +++++++++++++++++++++++++ feature-control-bff/package.json | 10 + feature-control-bff/server.mjs | 34 +++ feature-control-bff/tests/bff.test.mjs | 275 ++++++++++++++++++++ 5 files changed, 741 insertions(+) create mode 100644 feature-control-bff/README.md create mode 100644 feature-control-bff/core.mjs create mode 100644 feature-control-bff/package.json create mode 100644 feature-control-bff/server.mjs create mode 100644 feature-control-bff/tests/bff.test.mjs diff --git a/feature-control-bff/README.md b/feature-control-bff/README.md new file mode 100644 index 0000000..ac6f863 --- /dev/null +++ b/feature-control-bff/README.md @@ -0,0 +1,89 @@ +# Feature Control BFF + +BFF mínimo para expor flags ao app sem expor `Atomenta-Token` no cliente. + +## Endpoint + +- `POST /feature-control/bootstrap` +- `POST /feature-control/telemetry/exposure` +- `GET /health` + +## Variáveis de ambiente + +- `PORT` (default: `8787`) +- `ATOMENTA_ORIGIN` (default: `https://atomenta.com.br`) +- `FEATURE_CONTROL_MODULE_TOKEN` (ou `ATOMENTA_FEATURE_CONTROL_MODULE_ID`) **obrigatório** +- `FEATURE_CONTROL_ENVIRONMENT` (default: `production`) +- `FEATURE_CONTROL_CACHE_TTL_MS` (default: `60000`) +- `FEATURE_CONTROL_TIMEOUT_MS` (default: `2000`) +- `FEATURE_CONTROL_DEFAULTS_JSON` (opcional, JSON com defaults de fallback) + +Exemplo de `FEATURE_CONTROL_DEFAULTS_JSON`: + +```json +{ + "fc.checkout_v2": { "enabled": false, "variant": "off" }, + "fc.search_ranking_v3": { "enabled": true, "variant": "on", "payload": { "model": "v3" } } +} +``` + +## Request (App -> BFF) + +```json +{ + "environment": "production", + "keys": ["fc.checkout_v2", "fc.search_ranking_v3"], + "context": { + "subjectType": "customer", + "subjectId": "cust_123", + "storeId": "store_001", + "platform": "ios", + "appVersion": "2.3.1", + "attributes": { + "city": "Belo Horizonte", + "tier": "gold" + } + } +} +``` + +Se o app enviar `Authorization: Bearer `, o BFF tenta extrair `subjectId` do JWT (`sub`/`customerId`/`id`) quando `context.subjectId` não vier. + +## Response (BFF -> App) + +```json +{ + "ok": true, + "source": "live", + "configVersion": 7, + "evaluatedAt": "2026-04-16T12:00:00.000Z", + "flags": { + "checkoutV2": true, + "searchRankingV3": "off" + }, + "raw": { + "fc.checkout_v2": { + "enabled": true, + "variant": "on", + "payload": null, + "reason": "rollout" + } + } +} +``` + +Quando o upstream falha (ex.: `429`, `500`, timeout), o BFF devolve `fallback` com defaults estáticos. + +## Execução local + +```bash +cd feature-control-bff +FEATURE_CONTROL_MODULE_TOKEN="550e8400-e29b-41d4-a716-44665544000b" npm start +``` + +## Testes + +```bash +cd feature-control-bff +npm test +``` diff --git a/feature-control-bff/core.mjs b/feature-control-bff/core.mjs new file mode 100644 index 0000000..d5e3a22 --- /dev/null +++ b/feature-control-bff/core.mjs @@ -0,0 +1,333 @@ +import crypto from "node:crypto"; + +export function loadConfig(env = process.env) { + return { + atomentaOrigin: (env.ATOMENTA_ORIGIN || "https://atomenta.com.br").replace(/\/+$/, ""), + moduleToken: env.FEATURE_CONTROL_MODULE_TOKEN || env.ATOMENTA_FEATURE_CONTROL_MODULE_ID || "", + defaultEnvironment: env.FEATURE_CONTROL_ENVIRONMENT || "production", + cacheTtlMs: Number(env.FEATURE_CONTROL_CACHE_TTL_MS || 60_000), + requestTimeoutMs: Number(env.FEATURE_CONTROL_TIMEOUT_MS || 2_000), + staticDefaults: parseDefaults(env.FEATURE_CONTROL_DEFAULTS_JSON) + }; +} + +export function createRuntime(config) { + return { cache: new Map(), inFlight: new Map(), config }; +} + +export function nowIso() { + return new Date().toISOString(); +} + +export function writeJson(res, status, payload) { + const body = JSON.stringify(payload); + res.writeHead(status, { + "Content-Type": "application/json; charset=utf-8", + "Content-Length": Buffer.byteLength(body), + "Cache-Control": "no-store" + }); + res.end(body); +} + +const MAX_BODY_BYTES = 128 * 1024; +const MAX_KEYS = 100; +const MAX_ATTRIBUTES_BYTES = 16 * 1024; +const MAX_EXPOSURE_EVENTS = 100; + +function parseDefaults(raw) { + if (!raw) return {}; + const parsed = safeParseJson(raw); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {}; + return parsed; +} + +function safeParseJson(input) { + try { + return JSON.parse(input); + } catch { + return null; + } +} + +function normalizeString(value) { + return typeof value === "string" ? value.trim() : ""; +} + +function getSubjectFromAuthHeader(authHeader) { + if (!authHeader || !authHeader.toLowerCase().startsWith("bearer ")) return null; + const token = authHeader.slice(7).trim(); + const parts = token.split("."); + if (parts.length < 2) return null; + + try { + const payloadBase64 = parts[1].replace(/-/g, "+").replace(/_/g, "/"); + const padded = payloadBase64 + "=".repeat((4 - (payloadBase64.length % 4)) % 4); + const payload = JSON.parse(Buffer.from(padded, "base64").toString("utf-8")); + const subjectId = normalizeString(payload.sub) || normalizeString(payload.customerId) || normalizeString(payload.id); + if (!subjectId) return null; + return { subjectType: "customer", subjectId }; + } catch { + return null; + } +} + +function buildCacheKey(input) { + const stable = JSON.stringify({ + env: input.environment, + keys: input.keys, + context: { + subjectType: input.context.subjectType, + subjectId: input.context.subjectId, + storeId: input.context.storeId || "", + platform: input.context.platform || "", + appVersion: input.context.appVersion || "", + attributes: input.context.attributes || {} + } + }); + return crypto.createHash("sha256").update(stable).digest("hex"); +} + +async function readBody(req) { + const chunks = []; + let size = 0; + for await (const chunk of req) { + size += chunk.length; + if (size > MAX_BODY_BYTES) { + throw Object.assign(new Error("Payload muito grande"), { status: 413, code: "PAYLOAD_TOO_LARGE" }); + } + chunks.push(chunk); + } + return Buffer.concat(chunks).toString("utf-8"); +} + +function validateBootstrapRequest(body, authHeader, config) { + if (!body || typeof body !== "object" || Array.isArray(body)) { + throw Object.assign(new Error("Body inválido"), { status: 400, code: "INVALID_BODY" }); + } + + const keys = Array.isArray(body.keys) ? body.keys.map(normalizeString).filter(Boolean) : []; + if (!keys.length) throw Object.assign(new Error("`keys` é obrigatório"), { status: 400, code: "FEATURE_CONTROL_KEYS_REQUIRED" }); + if (keys.length > MAX_KEYS) { + throw Object.assign(new Error(`Máximo de ${MAX_KEYS} chaves por request`), { status: 400, code: "FEATURE_CONTROL_TOO_MANY_KEYS" }); + } + + const environment = normalizeString(body.environment) || config.defaultEnvironment; + const contextInput = body.context && typeof body.context === "object" ? body.context : {}; + const fromJwt = getSubjectFromAuthHeader(authHeader); + const subjectType = normalizeString(contextInput.subjectType) || fromJwt?.subjectType || "customer"; + const subjectId = normalizeString(contextInput.subjectId) || fromJwt?.subjectId || ""; + if (!subjectId) { + throw Object.assign(new Error("`context.subjectId` é obrigatório"), { status: 400, code: "FEATURE_CONTROL_SUBJECT_ID_REQUIRED" }); + } + + const allowed = new Set(["user", "customer", "store", "anonymous"]); + if (!allowed.has(subjectType)) { + throw Object.assign(new Error("`context.subjectType` inválido"), { status: 400, code: "FEATURE_CONTROL_SUBJECT_TYPE_INVALID" }); + } + + const attributes = contextInput.attributes && typeof contextInput.attributes === "object" && !Array.isArray(contextInput.attributes) + ? contextInput.attributes + : {}; + + if (Buffer.byteLength(JSON.stringify(attributes), "utf-8") > MAX_ATTRIBUTES_BYTES) { + throw Object.assign(new Error("`context.attributes` excede limite"), { status: 400, code: "FEATURE_CONTROL_ATTRIBUTES_TOO_LARGE" }); + } + + return { + environment, + keys: [...new Set(keys)].sort(), + context: { + subjectType, + subjectId, + storeId: normalizeString(contextInput.storeId) || undefined, + platform: normalizeString(contextInput.platform) || undefined, + appVersion: normalizeString(contextInput.appVersion) || undefined, + attributes + } + }; +} + +function validateExposureRequest(body) { + if (!body || typeof body !== "object" || Array.isArray(body)) { + throw Object.assign(new Error("Body inválido"), { status: 400, code: "INVALID_BODY" }); + } + if (!Array.isArray(body.events) || body.events.length === 0) { + throw Object.assign(new Error("`events` é obrigatório"), { status: 400, code: "FEATURE_CONTROL_EVENTS_REQUIRED" }); + } + if (body.events.length > MAX_EXPOSURE_EVENTS) { + throw Object.assign(new Error(`Máximo de ${MAX_EXPOSURE_EVENTS} eventos por request`), { + status: 400, + code: "FEATURE_CONTROL_EXPOSURE_TOO_MANY_EVENTS" + }); + } + + const events = body.events.map((event) => ({ + featureKey: normalizeString(event?.featureKey), + variant: normalizeString(event?.variant) || "off", + subjectType: normalizeString(event?.subjectType) || "customer", + storeId: normalizeString(event?.storeId) || undefined + })); + + for (const event of events) { + if (!event.featureKey) { + throw Object.assign(new Error("`featureKey` é obrigatório"), { status: 400, code: "FEATURE_CONTROL_EXPOSURE_FEATURE_KEY_REQUIRED" }); + } + } + return { events }; +} + +function buildFallbackFlags(keys, config) { + const raw = {}; + const simplified = {}; + + for (const key of keys) { + const fromEnv = config.staticDefaults[key]; + const enabled = Boolean(fromEnv?.enabled); + const variant = typeof fromEnv?.variant === "string" ? fromEnv.variant : enabled ? "on" : "off"; + raw[key] = { enabled, variant, payload: fromEnv?.payload ?? null, reason: "fallback_default" }; + } + + for (const [key, value] of Object.entries(raw)) { + const simplifiedKey = key.replace(/^fc\./, "").replace(/[_-]([a-z])/g, (_, c) => c.toUpperCase()).replace(/\./g, ""); + if (!simplified[simplifiedKey]) { + simplified[simplifiedKey] = value.variant === "on" || value.enabled === true ? true : value.variant; + } + } + + return { raw, simplified }; +} + +function mapUpstreamError(error) { + return { + status: Number(error?.status || 500), + code: normalizeString(error?.code) || "FEATURE_CONTROL_BFF_ERROR", + message: normalizeString(error?.message) || "Erro ao avaliar flags" + }; +} + +async function callAtomenta(path, requestPayload, config) { + if (!config.moduleToken) { + throw Object.assign(new Error("Token de módulo não configurado"), { status: 500, code: "FEATURE_CONTROL_MODULE_TOKEN_MISSING" }); + } + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), config.requestTimeoutMs); + + try { + const response = await fetch(`${config.atomentaOrigin}${path}`, { + method: "POST", + signal: controller.signal, + headers: { + "Content-Type": "application/json", + "Accept": "application/json", + "Atomenta-Token": config.moduleToken + }, + body: JSON.stringify(requestPayload) + }); + + const text = await response.text(); + const parsed = safeParseJson(text) || {}; + if (!response.ok) { + const upstreamCode = parsed.code || "FEATURE_CONTROL_UPSTREAM_ERROR"; + const upstreamMessage = parsed.message || `Erro upstream (${response.status})`; + throw Object.assign(new Error(upstreamMessage), { status: response.status, code: upstreamCode }); + } + return parsed; + } catch (error) { + if (error.name === "AbortError") { + throw Object.assign(new Error("Timeout no upstream"), { status: 504, code: "FEATURE_CONTROL_UPSTREAM_TIMEOUT" }); + } + throw error; + } finally { + clearTimeout(timeout); + } +} + +function formatBffResponse(evaluated, requestPayload, source = "live") { + const result = evaluated?.result || {}; + const flags = result.flags && typeof result.flags === "object" ? result.flags : {}; + const simplified = {}; + for (const key of requestPayload.keys) { + const value = flags[key]; + if (!value || typeof value !== "object") continue; + const simplifiedKey = key.replace(/^fc\./, "").replace(/[_-]([a-z])/g, (_, c) => c.toUpperCase()).replace(/\./g, ""); + if (!simplified[simplifiedKey]) simplified[simplifiedKey] = value.variant === "on" || value.enabled === true ? true : value.variant || "off"; + } + return { + ok: true, + source, + configVersion: Number(result.configVersion || 0), + evaluatedAt: result.evaluatedAt || nowIso(), + flags: simplified, + raw: flags + }; +} + +async function evaluateWithCache(requestPayload, runtime) { + const cacheKey = buildCacheKey(requestPayload); + const current = runtime.cache.get(cacheKey); + if (current && current.expiresAt > Date.now()) return current.payload; + + const inflight = runtime.inFlight.get(cacheKey); + if (inflight) return inflight; + + const work = (async () => { + const evaluated = await callAtomenta("/api/feature-control/evaluate", requestPayload, runtime.config); + const payload = formatBffResponse(evaluated, requestPayload, "live"); + runtime.cache.set(cacheKey, { payload, expiresAt: Date.now() + runtime.config.cacheTtlMs }); + return payload; + })(); + runtime.inFlight.set(cacheKey, work); + + try { + return await work; + } finally { + runtime.inFlight.delete(cacheKey); + } +} + +export async function handleBootstrap(req, res, runtime) { + try { + const body = safeParseJson(await readBody(req)); + if (!body) return writeJson(res, 400, { ok: false, code: "INVALID_JSON", message: "JSON inválido" }); + const requestPayload = validateBootstrapRequest(body, req.headers.authorization, runtime.config); + try { + const payload = await evaluateWithCache(requestPayload, runtime); + return writeJson(res, 200, payload); + } catch (error) { + const mapped = mapUpstreamError(error); + const fallback = buildFallbackFlags(requestPayload.keys, runtime.config); + return writeJson(res, mapped.status === 400 ? 400 : 200, { + ok: mapped.status === 400 ? false : true, + source: "fallback", + configVersion: 0, + evaluatedAt: nowIso(), + flags: fallback.simplified, + raw: fallback.raw, + upstreamError: mapped + }); + } + } catch (error) { + return writeJson(res, Number(error?.status || 500), { + ok: false, + code: normalizeString(error?.code) || "FEATURE_CONTROL_BFF_ERROR", + message: normalizeString(error?.message) || "Erro interno" + }); + } +} + +export async function handleExposure(req, res, runtime) { + try { + const body = safeParseJson(await readBody(req)); + if (!body) return writeJson(res, 400, { ok: false, code: "INVALID_JSON", message: "JSON inválido" }); + const requestPayload = validateExposureRequest(body); + const response = await callAtomenta("/api/feature-control/telemetry/exposure", requestPayload, runtime.config); + return writeJson(res, 200, { ok: true, code: "FEATURE_CONTROL_EXPOSURE_ACCEPTED", result: response?.result ?? null }); + } catch (error) { + return writeJson(res, Number(error?.status || 500), { + ok: false, + code: normalizeString(error?.code) || "FEATURE_CONTROL_EXPOSURE_ERROR", + message: normalizeString(error?.message) || "Falha ao enviar exposição" + }); + } +} diff --git a/feature-control-bff/package.json b/feature-control-bff/package.json new file mode 100644 index 0000000..6e8d9f9 --- /dev/null +++ b/feature-control-bff/package.json @@ -0,0 +1,10 @@ +{ + "name": "feature-control-bff", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "start": "node server.mjs", + "test": "node --test tests/*.test.mjs" + } +} diff --git a/feature-control-bff/server.mjs b/feature-control-bff/server.mjs new file mode 100644 index 0000000..e8188cc --- /dev/null +++ b/feature-control-bff/server.mjs @@ -0,0 +1,34 @@ +import http from "node:http"; +import { createRuntime, handleBootstrap, handleExposure, loadConfig, nowIso, writeJson } from "./core.mjs"; + +const PORT = Number(process.env.PORT || 8787); +const runtime = createRuntime(loadConfig(process.env)); + +const server = http.createServer(async (req, res) => { + const method = req.method || "GET"; + const url = req.url || "/"; + + if (method === "GET" && url === "/health") { + return writeJson(res, 200, { + ok: true, + service: "feature-control-bff", + time: nowIso(), + cacheEntries: runtime.cache.size, + inFlight: runtime.inFlight.size + }); + } + + if (method === "POST" && url === "/feature-control/bootstrap") { + return handleBootstrap(req, res, runtime); + } + + if (method === "POST" && url === "/feature-control/telemetry/exposure") { + return handleExposure(req, res, runtime); + } + + return writeJson(res, 404, { ok: false, code: "NOT_FOUND", message: "Rota não encontrada" }); +}); + +server.listen(PORT, () => { + console.log(`[feature-control-bff] listening on :${PORT}`); +}); diff --git a/feature-control-bff/tests/bff.test.mjs b/feature-control-bff/tests/bff.test.mjs new file mode 100644 index 0000000..98ba28d --- /dev/null +++ b/feature-control-bff/tests/bff.test.mjs @@ -0,0 +1,275 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import http from "node:http"; +import { spawn } from "node:child_process"; + +function listen(server) { + return new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + resolve(address.port); + }); + }); +} + +function closeServer(server) { + return new Promise((resolve) => server.close(() => resolve())); +} + +async function waitForHealth(url, timeoutMs = 5000) { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + try { + const response = await fetch(`${url}/health`); + if (response.ok) return; + } catch {} + await new Promise((resolve) => setTimeout(resolve, 100)); + } + throw new Error("BFF health timeout"); +} + +async function startBff(env) { + const child = spawn("node", ["server.mjs"], { + cwd: new URL("..", import.meta.url), + env: { ...process.env, ...env }, + stdio: ["ignore", "pipe", "pipe"] + }); + + const logs = []; + child.stdout.on("data", (chunk) => logs.push(chunk.toString("utf-8"))); + child.stderr.on("data", (chunk) => logs.push(chunk.toString("utf-8"))); + + const port = Number(env.PORT); + const baseUrl = `http://127.0.0.1:${port}`; + await waitForHealth(baseUrl); + + return { + baseUrl, + logs, + stop: async () => { + child.kill("SIGTERM"); + await new Promise((resolve) => child.on("exit", () => resolve())); + } + }; +} + +function createUpstreamStub(options = {}) { + const state = { + evaluateMode: options.evaluateMode || "success", + evaluatePayload: null, + exposurePayload: null + }; + + const server = http.createServer(async (req, res) => { + if (req.method === "POST" && req.url === "/api/feature-control/evaluate") { + const chunks = []; + for await (const chunk of req) chunks.push(chunk); + state.evaluatePayload = JSON.parse(Buffer.concat(chunks).toString("utf-8")); + + if (state.evaluateMode === "429") { + res.writeHead(429, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ code: "FEATURE_CONTROL_RATE_LIMIT", message: "rate" })); + return; + } + + if (state.evaluateMode === "500") { + res.writeHead(500, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ code: "FEATURE_CONTROL_ERROR", message: "boom" })); + return; + } + + if (state.evaluateMode === "timeout") { + await new Promise((resolve) => setTimeout(resolve, 600)); + } + + res.writeHead(200, { "Content-Type": "application/json" }); + res.end( + JSON.stringify({ + error: false, + code: "FEATURE_CONTROL_EVALUATED", + result: { + evaluatedAt: "2026-04-16T12:00:00.000Z", + configVersion: 7, + flags: { + "fc.checkout_v2": { enabled: true, variant: "on", payload: null, reason: "rollout" }, + "fc.search_ranking_v3": { enabled: false, variant: "off", payload: null, reason: "default" } + } + } + }) + ); + return; + } + + if (req.method === "POST" && req.url === "/api/feature-control/telemetry/exposure") { + const chunks = []; + for await (const chunk of req) chunks.push(chunk); + state.exposurePayload = JSON.parse(Buffer.concat(chunks).toString("utf-8")); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: false, code: "FEATURE_CONTROL_EXPOSURE_TRACKED", result: { accepted: true } })); + return; + } + + res.writeHead(404).end(); + }); + + return { server, state }; +} + +function bootstrapBody() { + return { + environment: "production", + keys: ["fc.checkout_v2", "fc.search_ranking_v3"], + context: { + subjectType: "customer", + subjectId: "cust_123", + storeId: "store_001", + platform: "ios", + appVersion: "2.3.1", + attributes: { city: "Campinas" } + } + }; +} + +test("bootstrap sucesso retorna flags simplificadas e raw", async () => { + const upstream = createUpstreamStub({ evaluateMode: "success" }); + const upstreamPort = await listen(upstream.server); + const bffPort = upstreamPort + 1000; + const bff = await startBff({ + PORT: String(bffPort), + ATOMENTA_ORIGIN: `http://127.0.0.1:${upstreamPort}`, + FEATURE_CONTROL_MODULE_TOKEN: "token", + FEATURE_CONTROL_TIMEOUT_MS: "300" + }); + + try { + const response = await fetch(`${bff.baseUrl}/feature-control/bootstrap`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(bootstrapBody()) + }); + assert.equal(response.status, 200); + const json = await response.json(); + assert.equal(json.ok, true); + assert.equal(json.configVersion, 7); + assert.equal(json.flags.checkoutV2, true); + assert.equal(json.flags.searchRankingV3, "off"); + assert.ok(json.raw["fc.checkout_v2"]); + assert.equal(upstream.state.evaluatePayload.context.subjectId, "cust_123"); + } finally { + await bff.stop(); + await closeServer(upstream.server); + } +}); + +test("bootstrap com 429 devolve fallback sem quebrar cliente", async () => { + const upstream = createUpstreamStub({ evaluateMode: "429" }); + const upstreamPort = await listen(upstream.server); + const bffPort = upstreamPort + 1000; + const bff = await startBff({ + PORT: String(bffPort), + ATOMENTA_ORIGIN: `http://127.0.0.1:${upstreamPort}`, + FEATURE_CONTROL_MODULE_TOKEN: "token", + FEATURE_CONTROL_DEFAULTS_JSON: JSON.stringify({ + "fc.checkout_v2": { enabled: false, variant: "off" } + }) + }); + + try { + const response = await fetch(`${bff.baseUrl}/feature-control/bootstrap`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(bootstrapBody()) + }); + assert.equal(response.status, 200); + const json = await response.json(); + assert.equal(json.source, "fallback"); + assert.equal(json.upstreamError.status, 429); + assert.equal(json.flags.checkoutV2, "off"); + } finally { + await bff.stop(); + await closeServer(upstream.server); + } +}); + +test("bootstrap com timeout devolve fallback", async () => { + const upstream = createUpstreamStub({ evaluateMode: "timeout" }); + const upstreamPort = await listen(upstream.server); + const bffPort = upstreamPort + 1000; + const bff = await startBff({ + PORT: String(bffPort), + ATOMENTA_ORIGIN: `http://127.0.0.1:${upstreamPort}`, + FEATURE_CONTROL_MODULE_TOKEN: "token", + FEATURE_CONTROL_TIMEOUT_MS: "100" + }); + + try { + const response = await fetch(`${bff.baseUrl}/feature-control/bootstrap`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(bootstrapBody()) + }); + assert.equal(response.status, 200); + const json = await response.json(); + assert.equal(json.source, "fallback"); + assert.equal(json.upstreamError.code, "FEATURE_CONTROL_UPSTREAM_TIMEOUT"); + } finally { + await bff.stop(); + await closeServer(upstream.server); + } +}); + +test("telemetria de exposure encaminha lote para upstream", async () => { + const upstream = createUpstreamStub({ evaluateMode: "success" }); + const upstreamPort = await listen(upstream.server); + const bffPort = upstreamPort + 1000; + const bff = await startBff({ + PORT: String(bffPort), + ATOMENTA_ORIGIN: `http://127.0.0.1:${upstreamPort}`, + FEATURE_CONTROL_MODULE_TOKEN: "token" + }); + + try { + const response = await fetch(`${bff.baseUrl}/feature-control/telemetry/exposure`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + events: [ + { featureKey: "fc.checkout_v2", variant: "on", subjectType: "customer", storeId: "store_1" } + ] + }) + }); + assert.equal(response.status, 200); + const json = await response.json(); + assert.equal(json.ok, true); + assert.equal(upstream.state.exposurePayload.events[0].featureKey, "fc.checkout_v2"); + } finally { + await bff.stop(); + await closeServer(upstream.server); + } +}); + +test("telemetria rejeita payload sem eventos", async () => { + const upstream = createUpstreamStub({ evaluateMode: "success" }); + const upstreamPort = await listen(upstream.server); + const bffPort = upstreamPort + 1000; + const bff = await startBff({ + PORT: String(bffPort), + ATOMENTA_ORIGIN: `http://127.0.0.1:${upstreamPort}`, + FEATURE_CONTROL_MODULE_TOKEN: "token" + }); + + try { + const response = await fetch(`${bff.baseUrl}/feature-control/telemetry/exposure`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ events: [] }) + }); + assert.equal(response.status, 400); + const json = await response.json(); + assert.equal(json.ok, false); + assert.equal(json.code, "FEATURE_CONTROL_EVENTS_REQUIRED"); + } finally { + await bff.stop(); + await closeServer(upstream.server); + } +}); From 7f7d414e6cb74ef43ce6e105c6f25d4bc9f70301 Mon Sep 17 00:00:00 2001 From: Daniel Arantes Loverde Date: Thu, 16 Apr 2026 14:48:39 -0300 Subject: [PATCH 2/3] feat(app): integrate Atomenta Feature Control bootstrap, cache and resume refresh --- .../Sources/PediFoods/ContentView.swift | 80 ++++- .../Sources/PediFoods/PediFoodsApp.swift | 1 + .../PediFoods/Services/ApiConfig.swift | 11 + .../Services/FeatureControlService.swift | 287 ++++++++++++++++++ .../PediFoods/Services/SessionEvents.swift | 1 + .../Sources/PediFoods/State/AppState.swift | 54 ++++ 6 files changed, 433 insertions(+), 1 deletion(-) create mode 100644 pedi-foods/Sources/PediFoods/Services/FeatureControlService.swift diff --git a/pedi-foods/Sources/PediFoods/ContentView.swift b/pedi-foods/Sources/PediFoods/ContentView.swift index fe2173c..5beb618 100644 --- a/pedi-foods/Sources/PediFoods/ContentView.swift +++ b/pedi-foods/Sources/PediFoods/ContentView.swift @@ -1,3 +1,4 @@ +import Foundation import SwiftUI struct ContentView: View { @@ -15,6 +16,7 @@ struct ContentView: View { @State private var sessionExpiredObserver: NSObjectProtocol? #endif @State var cartResetObserver: Any? + @State var appResumeObserver: Any? #if os(Android) @State var snackbarCenter = SnackbarCenter.shared #else @@ -80,14 +82,26 @@ struct ContentView: View { } } } + .onChange(of: appState.profile.id) { _, _ in + Task { @MainActor in + await refreshFeatureFlags(forceRefresh: true) + } + } + .onChange(of: appState.cart.storeId) { _, _ in + Task { @MainActor in + await refreshFeatureFlags(forceRefresh: true) + } + } .onAppear { attachCartResetObserverIfNeeded() + attachAppResumeObserverIfNeeded() #if os(iOS) attachSessionExpiredObserverIfNeeded() #endif } .onDisappear { detachCartResetObserver() + detachAppResumeObserver() #if os(iOS) detachSessionExpiredObserver() #endif @@ -179,6 +193,7 @@ struct ContentView: View { // Keep local state when backend refresh fails transiently. } + await refreshFeatureFlags(forceRefresh: false) isBootstrappingSession = false } @@ -189,7 +204,9 @@ struct ContentView: View { object: nil, queue: nil ) { _ in - appState.cart = CartState() + Task { @MainActor in + appState.cart = CartState() + } } } @@ -199,6 +216,26 @@ struct ContentView: View { self.cartResetObserver = nil } + private func attachAppResumeObserverIfNeeded() { + guard appResumeObserver == nil else { return } + appResumeObserver = NotificationCenter.default.addObserver( + forName: .appDidResume, + object: nil, + queue: nil + ) { _ in + Task { @MainActor in + guard root == .main else { return } + await refreshFeatureFlags(forceRefresh: true) + } + } + } + + private func detachAppResumeObserver() { + guard let appResumeObserver else { return } + NotificationCenter.default.removeObserver(appResumeObserver) + self.appResumeObserver = nil + } + @MainActor private func hydrateAppState(with customer: CustomerProfile) { appState.profile.id = customer.id @@ -206,6 +243,7 @@ struct ContentView: View { appState.profile.email = customer.email appState.profile.phone = customer.phoneNumber ?? "" appState.profile.profilePicture = customer.profilePicture ?? "" + appState.favorites.storeIds = Set(customer.favorites ?? []) SessionStateStore.setActiveUserKey( SessionStateStore.makeUserKey(profileId: customer.id, email: customer.email) ) @@ -299,6 +337,46 @@ struct ContentView: View { scheduleDisableAuthEntryPreparation() } + @MainActor + private func refreshFeatureFlags(forceRefresh: Bool) async { + guard root == .main else { return } + + let subjectType: String + let subjectId: String + if let profileId = appState.profile.id?.trimmingCharacters(in: .whitespacesAndNewlines), profileId.isEmpty == false { + subjectType = "customer" + subjectId = profileId + } else { + subjectType = "anonymous" + subjectId = "anonymous-device" + } + + var attrs: [String: String] = [:] + let addressLabel = appState.address.display.trimmingCharacters(in: .whitespacesAndNewlines) + if addressLabel.isEmpty == false, addressLabel.lowercased() != "defina seu endereco" { + attrs["addressLabel"] = addressLabel + } + + let context = FeatureControlEvaluationContext( + subjectType: subjectType, + subjectId: subjectId, + storeId: appState.cart.storeId, + attributes: attrs + ) + + let snapshot = await FeatureControlService.shared.evaluate( + context: context, + jwt: appState.session.jwt, + forceRefresh: forceRefresh + ) + appState.featureFlags = snapshot + await FeatureControlService.shared.sendExposureEvents( + snapshot: snapshot, + context: context, + jwt: appState.session.jwt + ) + } + private func scheduleDisableAuthEntryPreparation() { Task { @MainActor in try? await Task.sleep(nanoseconds: 900_000_000) diff --git a/pedi-foods/Sources/PediFoods/PediFoodsApp.swift b/pedi-foods/Sources/PediFoods/PediFoodsApp.swift index 1d672a5..309fc70 100644 --- a/pedi-foods/Sources/PediFoods/PediFoodsApp.swift +++ b/pedi-foods/Sources/PediFoods/PediFoodsApp.swift @@ -39,6 +39,7 @@ let logger: Logger = Logger(subsystem: "com.br.pedifoods.app", category: "PediFo /* SKIP @bridge */public func onResume() { logger.debug("onResume") + NotificationCenter.default.post(name: .appDidResume, object: nil) } /* SKIP @bridge */public func onPause() { diff --git a/pedi-foods/Sources/PediFoods/Services/ApiConfig.swift b/pedi-foods/Sources/PediFoods/Services/ApiConfig.swift index 1fc39db..2c67a63 100644 --- a/pedi-foods/Sources/PediFoods/Services/ApiConfig.swift +++ b/pedi-foods/Sources/PediFoods/Services/ApiConfig.swift @@ -14,6 +14,17 @@ enum ApiConfig { return URL(string: raw) ?? URL(string: "https://atomenta.com.br")! } + static var featureControlBffURL: URL { + let raw = ProcessInfo.processInfo.environment["FEATURE_CONTROL_BFF_URL"] ?? "http://localhost:8787" + return URL(string: raw) ?? URL(string: "http://localhost:8787")! + } + + static var featureControlEnvironment: String { + let raw = ProcessInfo.processInfo.environment["FEATURE_CONTROL_ENVIRONMENT"] ?? "production" + let clean = raw.trimmingCharacters(in: .whitespacesAndNewlines) + return clean.isEmpty ? "production" : clean + } + // Tokens provided by backend modules static let storeToken = "550e8400-e29b-41d4-a716-446655440008" static let customerToken = "550e8400-e29b-41d4-a716-44665544000a" diff --git a/pedi-foods/Sources/PediFoods/Services/FeatureControlService.swift b/pedi-foods/Sources/PediFoods/Services/FeatureControlService.swift new file mode 100644 index 0000000..312a157 --- /dev/null +++ b/pedi-foods/Sources/PediFoods/Services/FeatureControlService.swift @@ -0,0 +1,287 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +struct FeatureControlRawFlag: Codable, Equatable { + let enabled: Bool + let variant: String + let payload: FeatureControlJSONValue? + let reason: String? +} + +enum FeatureControlJSONValue: Codable, Equatable { + case string(String) + case number(Double) + case bool(Bool) + case object([String: FeatureControlJSONValue]) + case array([FeatureControlJSONValue]) + case null + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if container.decodeNil() { + self = .null + } else if let value = try? container.decode(Bool.self) { + self = .bool(value) + } else if let value = try? container.decode(Double.self) { + self = .number(value) + } else if let value = try? container.decode(String.self) { + self = .string(value) + } else if let value = try? container.decode([String: FeatureControlJSONValue].self) { + self = .object(value) + } else if let value = try? container.decode([FeatureControlJSONValue].self) { + self = .array(value) + } else { + self = .null + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .string(let value): try container.encode(value) + case .number(let value): try container.encode(value) + case .bool(let value): try container.encode(value) + case .object(let value): try container.encode(value) + case .array(let value): try container.encode(value) + case .null: try container.encodeNil() + } + } +} + +private struct FeatureControlBootstrapRequest: Codable { + struct Context: Codable { + let subjectType: String + let subjectId: String + let storeId: String? + let platform: String + let appVersion: String + let attributes: [String: String] + } + + let environment: String + let keys: [String] + let context: Context +} + +private struct FeatureControlBootstrapResponse: Codable { + let ok: Bool + let source: String? + let configVersion: Int + let evaluatedAt: String? + let flags: [String: FeatureFlagValue] + let raw: [String: FeatureControlRawFlag] +} + +private struct FeatureControlExposureRequest: Codable { + struct Event: Codable { + let featureKey: String + let variant: String + let subjectType: String + let storeId: String? + } + + let events: [Event] +} + +private struct FeatureControlCacheEntry: Codable { + let expiresAtUnixMs: Int64 + let snapshot: FeatureFlagsState +} + +struct FeatureControlEvaluationContext { + let subjectType: String + let subjectId: String + let storeId: String? + let attributes: [String: String] +} + +@MainActor +final class FeatureControlService { + static let shared = FeatureControlService() + + private let session: URLSession + private let cacheTTL: TimeInterval + private let decoder = JSONDecoder() + private let encoder = JSONEncoder() + private let userDefaults: UserDefaults + private let defaultsPrefix = "feature-control.cache.v1." + + init( + session: URLSession = .shared, + cacheTTL: TimeInterval = 60, + userDefaults: UserDefaults = .standard + ) { + self.session = session + self.cacheTTL = cacheTTL + self.userDefaults = userDefaults + } + + func evaluate( + context: FeatureControlEvaluationContext, + jwt: String?, + forceRefresh: Bool = false + ) async -> FeatureFlagsState { + let key = storageKey(for: context) + if forceRefresh == false, let cached = loadFromCache(storageKey: key) { + return cached + } + + let requestBody = FeatureControlBootstrapRequest( + environment: ApiConfig.featureControlEnvironment, + keys: featureKeys(), + context: .init( + subjectType: context.subjectType, + subjectId: context.subjectId, + storeId: context.storeId, + platform: platformName(), + appVersion: appVersion(), + attributes: context.attributes + ) + ) + + do { + let response = try await performBootstrapRequest(body: requestBody, jwt: jwt) + let snapshot = FeatureFlagsState( + configVersion: response.configVersion, + evaluatedAt: response.evaluatedAt, + source: response.source ?? "live", + values: response.flags, + raw: response.raw + ) + saveToCache(snapshot: snapshot, storageKey: key) + return snapshot + } catch { + if let cached = loadFromCache(storageKey: key) { + return FeatureFlagsState( + configVersion: cached.configVersion, + evaluatedAt: cached.evaluatedAt, + source: "cache_fallback", + values: cached.values, + raw: cached.raw + ) + } + return FeatureFlagsState(source: "defaults") + } + } + + func sendExposureEvents(snapshot: FeatureFlagsState, context: FeatureControlEvaluationContext, jwt: String?) async { + guard snapshot.raw.isEmpty == false else { return } + + let events = snapshot.raw.compactMap { entry -> FeatureControlExposureRequest.Event? in + let key = entry.key + let value = entry.value + guard value.enabled || value.variant.lowercased() != "off" else { return nil } + return .init( + featureKey: key, + variant: value.variant, + subjectType: context.subjectType, + storeId: context.storeId + ) + } + + guard events.isEmpty == false else { return } + + let batched = Array(events.prefix(100)) + let payload = FeatureControlExposureRequest(events: batched) + guard let body = try? encoder.encode(payload) else { return } + + var request = URLRequest(url: ApiConfig.featureControlBffURL.appendingPathComponent("/feature-control/telemetry/exposure")) + request.httpMethod = "POST" + request.httpBody = body + request.timeoutInterval = 3 + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("application/json", forHTTPHeaderField: "Accept") + if let jwt, jwt.isEmpty == false { + request.setValue("Bearer \(jwt)", forHTTPHeaderField: "Authorization") + } + + _ = try? await session.data(for: request) + } + + private func performBootstrapRequest(body: FeatureControlBootstrapRequest, jwt: String?) async throws -> FeatureControlBootstrapResponse { + var request = URLRequest(url: ApiConfig.featureControlBffURL.appendingPathComponent("/feature-control/bootstrap")) + request.httpMethod = "POST" + request.httpBody = try encoder.encode(body) + request.timeoutInterval = 3 + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("application/json", forHTTPHeaderField: "Accept") + if let jwt, jwt.isEmpty == false { + request.setValue("Bearer \(jwt)", forHTTPHeaderField: "Authorization") + } + + let (data, response) = try await session.data(for: request) + guard let http = response as? HTTPURLResponse, (200...299).contains(http.statusCode) else { + throw NetworkError.invalidResponse + } + + return try decoder.decode(FeatureControlBootstrapResponse.self, from: data) + } + + private func storageKey(for context: FeatureControlEvaluationContext) -> String { + let tokens = [ + ApiConfig.featureControlEnvironment, + context.subjectType, + context.subjectId, + context.storeId ?? "none", + platformName(), + appVersion(), + featureKeys().joined(separator: "|") + ] + let base = tokens.joined(separator: "::") + .lowercased() + .replacingOccurrences(of: " ", with: "_") + return defaultsPrefix + base + } + + private func saveToCache(snapshot: FeatureFlagsState, storageKey: String) { + let expiresAt = Int64((Date().timeIntervalSince1970 + cacheTTL) * 1000) + let entry = FeatureControlCacheEntry(expiresAtUnixMs: expiresAt, snapshot: snapshot) + guard let data = try? encoder.encode(entry) else { return } + userDefaults.set(data, forKey: storageKey) + } + + private func loadFromCache(storageKey: String) -> FeatureFlagsState? { + guard let data = userDefaults.data(forKey: storageKey), + let entry = try? decoder.decode(FeatureControlCacheEntry.self, from: data) else { + return nil + } + + let now = Int64(Date().timeIntervalSince1970 * 1000) + guard entry.expiresAtUnixMs > now else { + userDefaults.removeObject(forKey: storageKey) + return nil + } + + return entry.snapshot + } + + private func featureKeys() -> [String] { + let raw = ProcessInfo.processInfo.environment["FEATURE_CONTROL_KEYS"] ?? "fc.checkout_v2,fc.search_ranking_v3" + let items = raw + .split(separator: ",") + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { $0.isEmpty == false } + return items.isEmpty ? ["fc.checkout_v2"] : items + } + + private func platformName() -> String { + #if os(Android) + return "android" + #else + return "ios" + #endif + } + + private func appVersion() -> String { + #if canImport(UIKit) || canImport(AppKit) + let version = (Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines) + if let version, version.isEmpty == false { + return version + } + #endif + return "0.0.0" + } +} diff --git a/pedi-foods/Sources/PediFoods/Services/SessionEvents.swift b/pedi-foods/Sources/PediFoods/Services/SessionEvents.swift index 4746314..a25cc72 100644 --- a/pedi-foods/Sources/PediFoods/Services/SessionEvents.swift +++ b/pedi-foods/Sources/PediFoods/Services/SessionEvents.swift @@ -4,4 +4,5 @@ extension Notification.Name { static let sessionExpired = Notification.Name("SessionExpiredNotification") static let cartDidReset = Notification.Name("CartDidResetNotification") static let orderReviewDidSave = Notification.Name("OrderReviewDidSaveNotification") + static let appDidResume = Notification.Name("AppDidResumeNotification") } diff --git a/pedi-foods/Sources/PediFoods/State/AppState.swift b/pedi-foods/Sources/PediFoods/State/AppState.swift index b48f28b..6788b70 100644 --- a/pedi-foods/Sources/PediFoods/State/AppState.swift +++ b/pedi-foods/Sources/PediFoods/State/AppState.swift @@ -6,10 +6,64 @@ struct AppState { var cart = CartState() var address = AddressState() var favorites = FavoritesState() + var featureFlags = FeatureFlagsState() var homeFilters = HomeFiltersState() var activeModal: AppModal? = nil } +enum FeatureFlagValue: Codable, Equatable { + case boolean(Bool) + case text(String) + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if let boolValue = try? container.decode(Bool.self) { + self = .boolean(boolValue) + return + } + if let stringValue = try? container.decode(String.self) { + self = .text(stringValue) + return + } + if let intValue = try? container.decode(Int.self) { + self = .text(String(intValue)) + return + } + if let doubleValue = try? container.decode(Double.self) { + self = .text(String(doubleValue)) + return + } + self = .text("off") + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .boolean(let value): + try container.encode(value) + case .text(let value): + try container.encode(value) + } + } + + var boolValue: Bool { + switch self { + case .boolean(let value): + return value + case .text(let value): + return value.lowercased() == "on" || value.lowercased() == "true" + } + } +} + +struct FeatureFlagsState: Codable, Equatable { + var configVersion: Int = 0 + var evaluatedAt: String? = nil + var source: String = "default" + var values: [String: FeatureFlagValue] = [:] + var raw: [String: FeatureControlRawFlag] = [:] +} + enum AppModal: String, Identifiable { case addressPicker case filters From 0ebb8542135c919ea216df3423be8d049c72a9df Mon Sep 17 00:00:00 2001 From: Daniel Arantes Loverde Date: Thu, 16 Apr 2026 14:50:07 -0300 Subject: [PATCH 3/3] updates --- API_Mobile_App.md | 172 ++++++++++- pedi-foods/Android/app/build.gradle.kts | 4 - .../PediFoods.xcodeproj/project.pbxproj | 2 +- pedi-foods/Package.swift | 4 +- .../PediFoods/Components/StoreCard.swift | 7 +- .../PediFoods/Resources/Localizable.xcstrings | 6 + .../Services/ApiFavoriteModels.swift | 6 + .../PediFoods/Services/ApiModels.swift | 2 + .../PediFoods/Services/ApiService.swift | 51 +++- .../Services/StoreCatalogNormalizer.swift | 280 ++++++++++++++++++ .../PediFoods/Views/Auth/OtpView.swift | 2 + .../Views/Main/HomeScrollOffsetObserver.swift | 87 ++---- .../Views/Main/HomeView+Favorites.swift | 42 +++ .../PediFoods/Views/Main/HomeView.swift | 22 +- .../Views/Main/StoreDetailSupport.swift | 141 ++------- .../Views/Main/StoreDetailView+Logic.swift | 79 ++++- .../Views/Main/StoreDetailView.swift | 14 +- .../FavoritesDecodingTests.swift | 49 +++ .../StoreCatalogNormalizerTests.swift | 117 ++++++++ sim-home.png | Bin 0 -> 137541 bytes 20 files changed, 871 insertions(+), 216 deletions(-) create mode 100644 pedi-foods/Sources/PediFoods/Services/ApiFavoriteModels.swift create mode 100644 pedi-foods/Sources/PediFoods/Services/StoreCatalogNormalizer.swift create mode 100644 pedi-foods/Sources/PediFoods/Views/Main/HomeView+Favorites.swift create mode 100644 pedi-foods/Tests/PediFoodsTests/FavoritesDecodingTests.swift create mode 100644 pedi-foods/Tests/PediFoodsTests/StoreCatalogNormalizerTests.swift create mode 100644 sim-home.png diff --git a/API_Mobile_App.md b/API_Mobile_App.md index 3150af2..007a3e5 100644 --- a/API_Mobile_App.md +++ b/API_Mobile_App.md @@ -167,6 +167,106 @@ Used when interacting with a specific store (Catalog, Checkout). - Se a distância entre `lat_long` enviado e o ponto do CEP for maior que `1.5 km`, a API substitui `lat_long` pelo ponto do CEP cacheado. - Objetivo: evitar discrepâncias grandes entre endereço e coordenada salva no perfil. +### 5. List Favorite Stores +**GET** `/api/customer/favorites` + +**Headers:** + +- `Authorization: Bearer ` +- `Atomenta-Token: 550e8400-e29b-41d4-a716-44665544000a` + +**Notes:** + +- Retorna as lojas favoritas do customer autenticado. +- Apenas lojas públicas/visíveis são retornadas. +- A ordem segue a ordem salva em `favorites`. + +**Response:** + +```json +{ + "error": false, + "code": "CUSTOMER_FAVORITES_RETRIEVED", + "result": [ + { + "id": "store_1775510226293_yn5ys", + "name": "CPS Drinks", + "logo": null, + "cover": null, + "category": "Doces & Bolos", + "rating": 4.8, + "totalReviews": 12, + "isOpen": true, + "statusLabel": "Aberto", + "nextOpenLabel": null + } + ] +} +``` + +### 6. Add Store to Favorites +**POST** `/api/customer/favorites/:storeId` + +**Headers:** + +- `Authorization: Bearer ` +- `Atomenta-Token: 550e8400-e29b-41d4-a716-44665544000a` + +**Notes:** + +- Salva a loja nos favoritos do customer autenticado. +- Se a loja já estiver favoritada, a operação continua idempotente. +- A loja precisa existir e estar publicamente visível. + +**Response:** + +```json +{ + "error": false, + "code": "CUSTOMER_FAVORITE_SAVED", + "result": { + "favorites": ["store_1775510226293_yn5ys"], + "store": { + "id": "store_1775510226293_yn5ys", + "name": "CPS Drinks", + "logo": null, + "cover": null, + "category": "Doces & Bolos", + "rating": 4.8, + "totalReviews": 12, + "isOpen": true, + "statusLabel": "Aberto", + "nextOpenLabel": null + } + } +} +``` + +### 7. Remove Store from Favorites +**DELETE** `/api/customer/favorites/:storeId` + +**Headers:** + +- `Authorization: Bearer ` +- `Atomenta-Token: 550e8400-e29b-41d4-a716-44665544000a` + +**Notes:** + +- Remove a loja dos favoritos do customer autenticado. +- A operação é idempotente: se a loja não estiver favoritada, o array volta sem ela. + +**Response:** + +```json +{ + "error": false, + "code": "CUSTOMER_FAVORITE_REMOVED", + "result": { + "favorites": [] + } +} +``` + --- ## 🏠 App Home Screen @@ -538,19 +638,29 @@ Use este canal para acompanhar mudança de status do pedido em tempo real (sem d ``` **Eventos recebidos pelo app:** -- `order_update`: enviado quando o pedido muda (pagamento aprovado, aceito pela loja, saiu para entrega, concluído, cancelado). +- `order_update`: alteração geral de status do pedido. +- `order_cancelled`: cancelamento confirmado, com motivo e metadados de estorno. +- `delivery_order_cancelled`: evento dedicado para telas de OTP/entrega interromperem o fluxo imediatamente. -**Payload típico de `order_update`:** +**Payload típico de `order_cancelled`:** ```json { - "id": "ord_987...", - "shortId": "1234", - "storeId": "store_123...", - "userId": "cust_uuid...", - "status": "CONFIRMED", - "paymentStatus": "CONFIRMED", - "updatedAt": "2026-02-15T20:10:00.000Z" + "event": "order_cancelled", + "emittedAt": "2026-03-05T18:00:00.000Z", + "orderId": "ffdc47b4-6a55-4713-aea9-45169e4cf0d7", + "shortId": "3778", + "storeId": "store_1772117366848_wmqw4", + "userId": "cust_uuid_001", + "status": "CANCELED", + "cancelledBy": "store", + "cancelledAt": "2026-03-05T18:00:00.000Z", + "cancelReasonCode": "STORE_UNAVAILABLE", + "cancelReasonDetail": "Falta de insumo crítico para finalizar o pedido", + "refundStatus": "failed", + "refundIdempotencyKey": "refund:ffdc47b4-...", + "refundProviderRef": null, + "refundError": "Falha ao solicitar estorno automático" } ``` @@ -566,9 +676,45 @@ Se o socket cair, o app deve continuar consultando o status até estado final. - depois: a cada `10s` - parar em status final (`COMPLETED`, `CANCELED`, `REFUNDED`) ou ao sair da tela. -### 8. Catálogo Oficial de Tags de Review +### 8. Cancelamento de Pedido (Loja/Customer) -### 8.1 Matriz Completa de Endpoints (Reviews + Tags) +**Cancelamento pela loja (Store API):** +- `POST /api/store/:storeId/orders/:orderId/cancel` + +**Cancelamento pelo customer:** +- `POST /api/customer/orders/:orderId/cancel` + +**Request body (ambos):** +```json +{ + "reasonCode": "CUSTOMER_REQUEST", + "reasonDetail": "Cliente solicitou cancelamento antes da entrega" +} +``` + +**Motivos aceitos (`reasonCode`):** +- `STORE_UNAVAILABLE` +- `CUSTOMER_REQUEST` +- `DELIVERY_ISSUE` + +**Regras atuais de cancelamento:** +- Permitido em: `PAYMENT_PENDING`, `PENDING`, `ACCEPTED`, `PREPARING`, `READY`, `DELIVERING`. +- Bloqueado em: `COMPLETED` e `CANCELED`. +- Motoboy não cancela pedido. +- Ao cancelar, o backend invalida `otp`, `confirmOtp` e `customerOtp`. + +**Concorrência e idempotência (ponta a ponta):** +- Cancelamento é serializado por `orderId` (lock por pedido). +- Estorno automático usa lock + chave idempotente de estorno para evitar duplicidade. +- Em segunda tentativa após cancelamento efetivado, a API retorna `409 ORDER_ALREADY_CANCELED`. + +**Campos relevantes no retorno:** +- `cancelledBy`, `cancelledAt`, `cancelReasonCode`, `cancelReasonDetail` +- `refundStatus`, `refundIdempotencyKey`, `refundProviderRef` + +### 9. Catálogo Oficial de Tags de Review + +### 9.1 Matriz Completa de Endpoints (Reviews + Tags) **Público/App (Customer):** - `GET /api/public/reviews/tags` → catálogo oficial de tags e regras de validação. @@ -630,7 +776,7 @@ O app deve buscar este endpoint para renderizar as tags válidas e enviar soment - Não hardcodear tags no app; usar o catálogo do backend. - Enviar no POST de review apenas os `id` retornados em cada bloco. -### 9. Enviar Avaliação do Pedido +### 10. Enviar Avaliação do Pedido Permite o customer enviar review da loja a partir de um pedido finalizado. **Endpoint:** @@ -749,7 +895,7 @@ Exemplo inválido (entrega positiva com tags negativas): Retorno esperado: `400 INVALID_DELIVERY_SENTIMENT_TAGS`. -### 10. Analytics de Reviews (Store/API) +### 11. Analytics de Reviews (Store/API) Para dashboard da loja e análise de produto, usar: - `GET /api/store/:storeId/reviews` diff --git a/pedi-foods/Android/app/build.gradle.kts b/pedi-foods/Android/app/build.gradle.kts index 4752bf9..8a591a5 100644 --- a/pedi-foods/Android/app/build.gradle.kts +++ b/pedi-foods/Android/app/build.gradle.kts @@ -41,10 +41,6 @@ android { // versionName = MARKETING_VERSION } - buildFeatures { - buildConfig = true - } - lint { disable.add("Instantiatable") disable.add("MissingPermission") diff --git a/pedi-foods/Darwin/PediFoods.xcodeproj/project.pbxproj b/pedi-foods/Darwin/PediFoods.xcodeproj/project.pbxproj index 3edd5e9..57006fe 100644 --- a/pedi-foods/Darwin/PediFoods.xcodeproj/project.pbxproj +++ b/pedi-foods/Darwin/PediFoods.xcodeproj/project.pbxproj @@ -300,7 +300,7 @@ repositoryURL = "https://git.loverde.com.br/Loverde-Company-LTDA/LCEssentials.git"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 1.0.5; + minimumVersion = 1.0.7; }; }; /* End XCRemoteSwiftPackageReference section */ diff --git a/pedi-foods/Package.swift b/pedi-foods/Package.swift index 3d1ebe1..888bcd1 100644 --- a/pedi-foods/Package.swift +++ b/pedi-foods/Package.swift @@ -10,8 +10,8 @@ let package = Package( .library(name: "PediFoods", type: .dynamic, targets: ["PediFoods"]), ], dependencies: [ - .package(url: "https://source.skip.tools/skip.git", from: "1.7.0"), - .package(url: "https://source.skip.tools/skip-fuse-ui.git", from: "1.0.0"), + .package(url: "https://source.skip.tools/skip.git", from: "1.8.4"), + .package(path: "Vendor/skip-fuse-ui"), .package(url: "https://git.loverde.com.br/Loverde-Company-LTDA/LCEssentials.git", from: "1.0.5") ], targets: [ diff --git a/pedi-foods/Sources/PediFoods/Components/StoreCard.swift b/pedi-foods/Sources/PediFoods/Components/StoreCard.swift index 9f7c7ad..26de91f 100644 --- a/pedi-foods/Sources/PediFoods/Components/StoreCard.swift +++ b/pedi-foods/Sources/PediFoods/Components/StoreCard.swift @@ -2,6 +2,7 @@ import SwiftUI struct FeaturedStoreCard: View { let store: FeaturedStoreCardModel + var onFavoriteToggle: (() -> Void)? = nil var body: some View { VStack(alignment: .leading, spacing: 12) { @@ -21,7 +22,9 @@ struct FeaturedStoreCard: View { HStack { Spacer() - Button(action: {}) { + Button { + onFavoriteToggle?() + } label: { Image(systemName: store.isFavorite ? "heart.fill" : "heart") .foregroundStyle(store.isFavorite ? Color.red : AppColors.textMuted) .padding(8) @@ -29,6 +32,8 @@ struct FeaturedStoreCard: View { .clipShape(Circle()) .shadow(color: AppShadow.soft.color, radius: AppShadow.soft.radius, y: AppShadow.soft.y) } + .buttonStyle(.borderless) + .accessibilityLabel(store.isFavorite ? "Remover loja dos favoritos" : "Adicionar loja aos favoritos") .padding(10) } } diff --git a/pedi-foods/Sources/PediFoods/Resources/Localizable.xcstrings b/pedi-foods/Sources/PediFoods/Resources/Localizable.xcstrings index 2a72535..b6d87f9 100644 --- a/pedi-foods/Sources/PediFoods/Resources/Localizable.xcstrings +++ b/pedi-foods/Sources/PediFoods/Resources/Localizable.xcstrings @@ -153,6 +153,9 @@ "Adicionar fotos" : { "comment" : "A label for adding photos in the order review section.", "isCommentAutoGenerated" : true + }, + "Adicionar loja aos favoritos" : { + }, "Adicionar novo endereço" : { "comment" : "A button label that translates to \"Add new address\" in English.", @@ -932,6 +935,9 @@ "Remover" : { "comment" : "A button that deletes the user's profile picture.", "isCommentAutoGenerated" : true + }, + "Remover loja dos favoritos" : { + }, "Resposta da loja" : { "comment" : "A heading for the reply from the store in an order review.", diff --git a/pedi-foods/Sources/PediFoods/Services/ApiFavoriteModels.swift b/pedi-foods/Sources/PediFoods/Services/ApiFavoriteModels.swift new file mode 100644 index 0000000..317be1d --- /dev/null +++ b/pedi-foods/Sources/PediFoods/Services/ApiFavoriteModels.swift @@ -0,0 +1,6 @@ +import Foundation + +struct CustomerFavoritesMutationResult: Decodable { + let favorites: [String] + let store: StoreSummary? +} diff --git a/pedi-foods/Sources/PediFoods/Services/ApiModels.swift b/pedi-foods/Sources/PediFoods/Services/ApiModels.swift index 8b54b96..4e207c3 100644 --- a/pedi-foods/Sources/PediFoods/Services/ApiModels.swift +++ b/pedi-foods/Sources/PediFoods/Services/ApiModels.swift @@ -21,6 +21,7 @@ struct CustomerProfile: Decodable { let email: String let phoneNumber: String? let profilePicture: String? + let favorites: [String]? let addressBook: [CustomerAddress]? enum CodingKeys: String, CodingKey { @@ -29,6 +30,7 @@ struct CustomerProfile: Decodable { case email case phoneNumber case profilePicture + case favorites case addressBook = "address_book" } } diff --git a/pedi-foods/Sources/PediFoods/Services/ApiService.swift b/pedi-foods/Sources/PediFoods/Services/ApiService.swift index da0e965..ee295f4 100644 --- a/pedi-foods/Sources/PediFoods/Services/ApiService.swift +++ b/pedi-foods/Sources/PediFoods/Services/ApiService.swift @@ -23,6 +23,7 @@ final class ApiService { private var tokenStore: TokenStore private let profileCachePrefix = "api:profile:" private let ordersCachePrefix = "api:orders:" + private let favoritesCachePrefix = "api:favorites:" private let publicCategoriesCacheKey = "api:public-categories" init(client: ApiClient = ApiClient(), tokenStore: TokenStore = DefaultTokenStore()) { @@ -68,10 +69,16 @@ final class ApiService { tokenStore.clear() AppContentCache.shared.invalidate(prefix: profileCachePrefix) AppContentCache.shared.invalidate(prefix: ordersCachePrefix) + AppContentCache.shared.invalidate(prefix: favoritesCachePrefix) AppContentCache.shared.invalidate(prefix: publicCategoriesCacheKey) NotificationCenter.default.post(name: .sessionExpired, object: message) } + private func invalidateFavoritesCache() { + AppContentCache.shared.invalidate(prefix: profileCachePrefix) + AppContentCache.shared.invalidate(prefix: favoritesCachePrefix) + } + private func scopedCacheSuffix() -> String { let jwt = tokenStore.jwt ?? "anonymous" if jwt.count <= 16 { return jwt } @@ -168,7 +175,7 @@ final class ApiService { if envelope.error == false, envelope.result != nil { AppContentCache.shared.set(envelope, for: scopedProfileKey, ttl: AppCacheTTL.twoHours) } else { - AppContentCache.shared.invalidate(prefix: profileCachePrefix) + invalidateFavoritesCache() } return envelope } @@ -241,11 +248,51 @@ final class ApiService { if envelope.error == false, envelope.result != nil { AppContentCache.shared.set(envelope, for: scopedProfileKey, ttl: AppCacheTTL.twoHours) } else { - AppContentCache.shared.invalidate(prefix: profileCachePrefix) + invalidateFavoritesCache() } return envelope } + func listFavoriteStores(forceRefresh: Bool = false) async throws -> ApiEnvelope<[StoreSummary]> { + let cacheKey = "\(favoritesCachePrefix)\(scopedCacheSuffix())" + if forceRefresh == false, + let cached: ApiEnvelope<[StoreSummary]> = AppContentCache.shared.value(for: cacheKey, as: ApiEnvelope<[StoreSummary]>.self) { + return cached + } + + let req = ApiRequest(path: "/api/customer/favorites", method: "GET", module: .customer, requiresAuth: true) + let envelope: ApiEnvelope<[StoreSummary]> = try await sendEnvelope(req) + if envelope.error == false, envelope.result != nil { + AppContentCache.shared.set(envelope, for: cacheKey, ttl: AppCacheTTL.twoHours) + } + return envelope + } + + func addStoreToFavorites(storeId: String) async throws -> ApiEnvelope { + let req = ApiRequest(path: "/api/customer/favorites/\(storeId)", method: "POST", module: .customer, requiresAuth: true) + let envelope: ApiEnvelope = try await sendEnvelope(req) + if envelope.error == false { + invalidateFavoritesCache() + } + return envelope + } + + func removeStoreFromFavorites(storeId: String) async throws -> ApiEnvelope { + let req = ApiRequest(path: "/api/customer/favorites/\(storeId)", method: "DELETE", module: .customer, requiresAuth: true) + let envelope: ApiEnvelope = try await sendEnvelope(req) + if envelope.error == false { + invalidateFavoritesCache() + } + return envelope + } + + func setStoreFavorite(storeId: String, isFavorite: Bool) async throws -> ApiEnvelope { + if isFavorite { + return try await addStoreToFavorites(storeId: storeId) + } + return try await removeStoreFromFavorites(storeId: storeId) + } + private func matchesAddress(_ lhs: CustomerAddress, _ rhs: CustomerAddress) -> Bool { lhs.label == rhs.label && lhs.address == rhs.address && diff --git a/pedi-foods/Sources/PediFoods/Services/StoreCatalogNormalizer.swift b/pedi-foods/Sources/PediFoods/Services/StoreCatalogNormalizer.swift new file mode 100644 index 0000000..563ee6e --- /dev/null +++ b/pedi-foods/Sources/PediFoods/Services/StoreCatalogNormalizer.swift @@ -0,0 +1,280 @@ +import Foundation + +enum StoreCatalogNormalizer { + static func sanitize(categories: [StoreCatalogCategory], storeId: String) -> [StoreCatalogCategory] { + var seenCategoryIds: Set = [] + + return categories.enumerated().map { categoryIndex, category in + let categoryId = makeUniqueId( + rawValue: category.id, + fallback: "\(storeId)-category-\(categoryIndex)", + seenIds: &seenCategoryIds + ) + + let normalizedPizzaConfig = sanitize( + pizzaConfig: category.pizzaConfig, + categoryId: categoryId + ) + + var seenProductIds: Set = [] + let normalizedProducts = category.products.enumerated().map { productIndex, product in + sanitize( + product: product, + categoryId: categoryId, + productIndex: productIndex, + seenProductIds: &seenProductIds + ) + } + + return StoreCatalogCategory( + id: categoryId, + name: category.name, + isPizzaCategory: category.isPizzaCategory, + pizzaConfig: normalizedPizzaConfig, + products: normalizedProducts + ) + } + } + + static func preferredCategoryId( + from categories: [StoreCatalogCategory], + preferredId: String? + ) -> String? { + guard let preferredId, preferredId.isEmpty == false else { + return categories.first?.id + } + + if categories.contains(where: { $0.id == preferredId }) { + return preferredId + } + + return categories.first?.id + } + + private static func sanitize( + pizzaConfig: StorePizzaConfig?, + categoryId: String + ) -> StorePizzaConfig? { + guard let pizzaConfig else { return nil } + + var seenSizeIds: Set = [] + let normalizedSizes = pizzaConfig.sizes.enumerated().map { index, size in + StorePizzaSize( + id: makeUniqueId( + rawValue: size.id, + fallback: "\(categoryId)-size-\(index)", + seenIds: &seenSizeIds + ), + name: size.name, + slices: size.slices, + maxFlavors: size.maxFlavors + ) + } + + var seenDoughIds: Set = [] + let normalizedDoughs = pizzaConfig.doughs.enumerated().map { index, dough in + StorePizzaDough( + id: makeUniqueId( + rawValue: dough.id, + fallback: "\(categoryId)-dough-\(index)", + seenIds: &seenDoughIds + ), + name: dough.name, + active: dough.active + ) + } + + var seenCrustIds: Set = [] + let normalizedCrusts = pizzaConfig.crusts.enumerated().map { index, crust in + StorePizzaCrust( + id: makeUniqueId( + rawValue: crust.id, + fallback: "\(categoryId)-crust-\(index)", + seenIds: &seenCrustIds + ), + name: crust.name, + active: crust.active, + priceModifier: crust.priceModifier + ) + } + + return StorePizzaConfig( + sizes: normalizedSizes, + doughs: normalizedDoughs, + crusts: normalizedCrusts + ) + } + + private static func sanitize( + product: StoreCatalogProduct, + categoryId: String, + productIndex: Int, + seenProductIds: inout Set + ) -> StoreCatalogProduct { + let productId = makeUniqueId( + rawValue: product.id, + fallback: "\(categoryId)-product-\(productIndex)", + seenIds: &seenProductIds + ) + + var seenGroupIds: Set = [] + var seenAddonItemIds: Set = [] + let normalizedAddonGroups = product.addonGroups.enumerated().map { groupIndex, group in + let groupId = makeUniqueId( + rawValue: group.id, + fallback: "\(productId)-group-\(groupIndex)", + seenIds: &seenGroupIds + ) + + let normalizedItems = group.items.enumerated().map { itemIndex, item in + StoreAddonItem( + id: makeUniqueId( + rawValue: item.id, + fallback: "\(groupId)-item-\(itemIndex)", + seenIds: &seenAddonItemIds + ), + name: item.name, + price: item.price + ) + } + + return StoreAddonGroup( + id: groupId, + name: group.name, + minSelectors: group.minSelectors, + maxSelectors: group.maxSelectors, + items: normalizedItems + ) + } + + return StoreCatalogProduct( + id: productId, + type: product.type, + name: product.name, + description: product.description, + image: product.image, + price: product.price, + originalPrice: product.originalPrice, + pizzaPrices: product.pizzaPrices, + addonGroups: normalizedAddonGroups + ) + } + + private static func makeUniqueId( + rawValue: String, + fallback: String, + seenIds: inout Set + ) -> String { + let trimmedRawValue = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) + let baseId = trimmedRawValue.isEmpty ? fallback : trimmedRawValue + + if seenIds.contains(baseId) == false { + seenIds.insert(baseId) + return baseId + } + + var suffix = 1 + while true { + let candidate = "\(baseId)-\(suffix)" + if seenIds.contains(candidate) == false { + seenIds.insert(candidate) + return candidate + } + suffix += 1 + } + } +} + +extension StoreCatalogCategory { + init( + id: String, + name: String, + isPizzaCategory: Bool, + pizzaConfig: StorePizzaConfig?, + products: [StoreCatalogProduct] + ) { + self.id = id + self.name = name + self.isPizzaCategory = isPizzaCategory + self.pizzaConfig = pizzaConfig + self.products = products + } +} + +extension StoreCatalogProduct { + init( + id: String, + type: String?, + name: String, + description: String?, + image: String?, + price: Double?, + originalPrice: Double?, + pizzaPrices: [String: Double], + addonGroups: [StoreAddonGroup] + ) { + self.id = id + self.type = type + self.name = name + self.description = description + self.image = image + self.price = price + self.originalPrice = originalPrice + self.pizzaPrices = pizzaPrices + self.addonGroups = addonGroups + } +} + +extension StoreAddonGroup { + init( + id: String, + name: String, + minSelectors: Int?, + maxSelectors: Int?, + items: [StoreAddonItem] + ) { + self.id = id + self.name = name + self.minSelectors = minSelectors + self.maxSelectors = maxSelectors + self.items = items + } +} + +extension StoreAddonItem { + init( + id: String, + name: String, + price: Double? + ) { + self.id = id + self.name = name + self.price = price + } +} + +extension StorePizzaConfig { + init( + sizes: [StorePizzaSize], + doughs: [StorePizzaDough], + crusts: [StorePizzaCrust] + ) { + self.sizes = sizes + self.doughs = doughs + self.crusts = crusts + } +} + +extension StorePizzaCrust { + init( + id: String, + name: String?, + active: Bool?, + priceModifier: Double? + ) { + self.id = id + self.name = name + self.active = active + self.priceModifier = priceModifier + } +} diff --git a/pedi-foods/Sources/PediFoods/Views/Auth/OtpView.swift b/pedi-foods/Sources/PediFoods/Views/Auth/OtpView.swift index 93c4a93..25c8e6c 100644 --- a/pedi-foods/Sources/PediFoods/Views/Auth/OtpView.swift +++ b/pedi-foods/Sources/PediFoods/Views/Auth/OtpView.swift @@ -285,6 +285,7 @@ struct OtpView: View { appState.profile.email = customer.email appState.profile.phone = customer.phoneNumber ?? "" appState.profile.profilePicture = customer.profilePicture ?? "" + appState.favorites.storeIds = Set(customer.favorites ?? []) SessionStateStore.setActiveUserKey( SessionStateStore.makeUserKey(profileId: customer.id, email: customer.email) ) @@ -315,6 +316,7 @@ struct OtpView: View { } appState.profile.email = email + appState.favorites = FavoritesState() SessionStateStore.setActiveUserKey( SessionStateStore.makeUserKey(profileId: nil, email: email) ) diff --git a/pedi-foods/Sources/PediFoods/Views/Main/HomeScrollOffsetObserver.swift b/pedi-foods/Sources/PediFoods/Views/Main/HomeScrollOffsetObserver.swift index 4489f2b..c5eef3d 100644 --- a/pedi-foods/Sources/PediFoods/Views/Main/HomeScrollOffsetObserver.swift +++ b/pedi-foods/Sources/PediFoods/Views/Main/HomeScrollOffsetObserver.swift @@ -1,76 +1,35 @@ import SwiftUI -#if canImport(UIKit) -import UIKit -#endif + +enum HomeScrollCoordinateSpace { + static let name = "home-scroll" +} #if os(iOS) -@MainActor -struct ScrollOffsetObserver: UIViewRepresentable { - let onOffsetChange: (CGFloat) -> Void +struct HomeScrollOffsetPreferenceKey: PreferenceKey { + static let defaultValue: CGFloat = 0 - func makeUIView(context: Context) -> ScrollOffsetProbeView { - let view = ScrollOffsetProbeView() - view.onOffsetChange = onOffsetChange - return view - } - - func updateUIView(_ uiView: ScrollOffsetProbeView, context: Context) { - uiView.onOffsetChange = onOffsetChange - uiView.attachIfNeeded() + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { + value = nextValue() } } -@MainActor -final class ScrollOffsetProbeView: UIView { - var onOffsetChange: (CGFloat) -> Void = { _ in } +struct ScrollOffsetObserver: View { + let onOffsetChange: (CGFloat) -> Void - private weak var observedScrollView: UIScrollView? - private var observation: NSKeyValueObservation? - - override init(frame: CGRect) { - super.init(frame: frame) - backgroundColor = .clear - isUserInteractionEnabled = false - } - - required init?(coder: NSCoder) { - super.init(coder: coder) - } - - deinit { - observation?.invalidate() - } - - override func didMoveToSuperview() { - super.didMoveToSuperview() - attachIfNeeded() - } - - override func didMoveToWindow() { - super.didMoveToWindow() - attachIfNeeded() - } - - func attachIfNeeded() { - guard let scrollView = findEnclosingScrollView() else { return } - guard scrollView !== observedScrollView else { return } - - observation?.invalidate() - observedScrollView = scrollView - observation = scrollView.observe(\.contentOffset, options: [.initial, .new]) { [weak self] sv, _ in - self?.onOffsetChange(sv.contentOffset.y) - } - } - - private func findEnclosingScrollView() -> UIScrollView? { - var view: UIView? = self - while let current = view { - if let scrollView = current as? UIScrollView { - return scrollView + var body: some View { + Color.clear + .frame(height: 0) + .background( + GeometryReader { geometry in + Color.clear.preference( + key: HomeScrollOffsetPreferenceKey.self, + value: geometry.frame(in: .named(HomeScrollCoordinateSpace.name)).minY + ) + } + ) + .onPreferenceChange(HomeScrollOffsetPreferenceKey.self) { minY in + onOffsetChange(-minY) } - view = current.superview - } - return nil } } #endif diff --git a/pedi-foods/Sources/PediFoods/Views/Main/HomeView+Favorites.swift b/pedi-foods/Sources/PediFoods/Views/Main/HomeView+Favorites.swift new file mode 100644 index 0000000..b5fcc40 --- /dev/null +++ b/pedi-foods/Sources/PediFoods/Views/Main/HomeView+Favorites.swift @@ -0,0 +1,42 @@ +import Foundation + +extension HomeView { + @MainActor + func toggleFavoriteStore(storeId: String, storeName: String) async { + guard favoriteRequestStoreIds.contains(storeId) == false else { return } + guard appState.session.isAuthenticated else { + SnackbarCenter.shared.show(title: "Faça login para favoritar lojas.", style: .warning, icon: "person.crop.circle.badge.exclamationmark", duration: 2.5) + return + } + + let isFavorite = appState.favorites.storeIds.contains(storeId) + favoriteRequestStoreIds.insert(storeId) + defer { favoriteRequestStoreIds.remove(storeId) } + + do { + let response = try await ApiService().setStoreFavorite(storeId: storeId, isFavorite: isFavorite == false) + guard response.error == false, let result = response.result else { + let message = response.message ?? "Não foi possível atualizar seus favoritos." + SnackbarCenter.shared.show(title: message, style: .error, icon: "xmark.octagon.fill", duration: 3.5) + return + } + + appState.favorites.storeIds = Set(result.favorites) + let successTitle = isFavorite + ? "\(storeName) removida dos favoritos." + : "\(storeName) adicionada aos favoritos." + let successIcon = isFavorite ? "heart.slash.fill" : "heart.fill" + SnackbarCenter.shared.show(title: successTitle, style: .success, icon: successIcon, duration: 2.2) + } catch { + let message: String + if let networkError = error as? NetworkError { + message = networkError.errorDescription ?? "Não foi possível atualizar seus favoritos." + } else if let serviceError = error as? ApiServiceError { + message = serviceError.errorDescription ?? "Não foi possível atualizar seus favoritos." + } else { + message = "Não foi possível atualizar seus favoritos." + } + SnackbarCenter.shared.show(title: message, style: .error, icon: "xmark.octagon.fill", duration: 3.5) + } + } +} diff --git a/pedi-foods/Sources/PediFoods/Views/Main/HomeView.swift b/pedi-foods/Sources/PediFoods/Views/Main/HomeView.swift index f2d28fd..2474a1d 100644 --- a/pedi-foods/Sources/PediFoods/Views/Main/HomeView.swift +++ b/pedi-foods/Sources/PediFoods/Views/Main/HomeView.swift @@ -19,6 +19,7 @@ struct HomeView: View { @State var stores: [StoreSummary] = [] @State var productSearchIndexByStoreId: [String: [String]] = [:] @State var searchDebounceToken = 0 + @State var favoriteRequestStoreIds: Set = [] private let specials: [SpecialOfferCardModel] = [ // .init(id: "ddddd", title: "Get 50% OFF", subtitle: "For your first order", colors: [Color(hex: "#1E6B43"), Color(hex: "#58A56C")]), @@ -47,7 +48,7 @@ struct HomeView: View { refreshCategories: true ) } - .background(scrollOffsetObserver) + .coordinateSpace(name: HomeScrollCoordinateSpace.name) header(collapseProgress: collapseProgress, height: headerHeight) .frame(maxWidth: .infinity, alignment: .top) @@ -84,6 +85,7 @@ struct HomeView: View { private var contentStack: some View { VStack(spacing: 24) { + scrollOffsetObserver categoriesSection section(title: "Featured") { @@ -102,7 +104,14 @@ struct HomeView: View { appState: $appState ) } label: { - FeaturedStoreCard(store: store) + FeaturedStoreCard( + store: store, + onFavoriteToggle: { + Task { + await toggleFavoriteStore(storeId: store.id, storeName: store.name) + } + } + ) .frame(width: 190) } .buttonStyle(.plain) @@ -171,7 +180,14 @@ struct HomeView: View { appState: $appState ) } label: { - FeaturedStoreCard(store: store) + FeaturedStoreCard( + store: store, + onFavoriteToggle: { + Task { + await toggleFavoriteStore(storeId: store.id, storeName: store.name) + } + } + ) } .buttonStyle(.plain) } diff --git a/pedi-foods/Sources/PediFoods/Views/Main/StoreDetailSupport.swift b/pedi-foods/Sources/PediFoods/Views/Main/StoreDetailSupport.swift index ba3b30c..1ba5e28 100644 --- a/pedi-foods/Sources/PediFoods/Views/Main/StoreDetailSupport.swift +++ b/pedi-foods/Sources/PediFoods/Views/Main/StoreDetailSupport.swift @@ -1,7 +1,4 @@ import SwiftUI -#if canImport(UIKit) -import UIKit -#endif struct CategoryHeaderOffsetPreferenceKey: PreferenceKey { static let defaultValue: [String: CGFloat] = [:] @@ -11,125 +8,41 @@ struct CategoryHeaderOffsetPreferenceKey: PreferenceKey { } } +enum StoreDetailScrollCoordinateSpace { + static let name = "store-detail-scroll" +} + +struct ScrollOffsetPreferenceKey: PreferenceKey { + static let defaultValue: CGFloat = 0 + + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { + value = nextValue() + } +} + struct ScrollOffsetReader: View { @Binding var offsetY: CGFloat var body: some View { -#if canImport(UIKit) - ScrollOffsetReaderRepresentable(offsetY: $offsetY) -#else Color.clear -#endif + .frame(height: 0) + .background( + GeometryReader { geometry in + Color.clear.preference( + key: ScrollOffsetPreferenceKey.self, + value: geometry.frame(in: .named(StoreDetailScrollCoordinateSpace.name)).minY + ) + } + ) + .onPreferenceChange(ScrollOffsetPreferenceKey.self) { minY in + let normalizedOffset = max(0, -minY) + if abs(offsetY - normalizedOffset) > 0.5 { + offsetY = normalizedOffset + } + } } } -#if canImport(UIKit) -struct ScrollOffsetReaderRepresentable: UIViewRepresentable { - @Binding var offsetY: CGFloat - - func makeUIView(context: Context) -> OffsetProbeView { - let view = OffsetProbeView() - view.onOffsetChanged = { value in - if offsetY != value { - offsetY = value - } - } - return view - } - - func updateUIView(_ uiView: OffsetProbeView, context: Context) { - uiView.onOffsetChanged = { value in - if offsetY != value { - offsetY = value - } - } - } -} - -final class OffsetProbeView: UIView { - var onOffsetChanged: ((CGFloat) -> Void)? - private var observation: NSKeyValueObservation? - private weak var observedScrollView: UIScrollView? - - override func didMoveToWindow() { - super.didMoveToWindow() - attachIfNeeded() - } - - override func didMoveToSuperview() { - super.didMoveToSuperview() - attachIfNeeded() - } - - private func attachIfNeeded() { - guard observation == nil else { return } - guard observedScrollView == nil else { return } - - if let scrollView = enclosingScrollView() ?? findScrollViewInWindow() { - observe(scrollView) - return - } - - retryAttach() - } - - private func observe(_ scrollView: UIScrollView) { - observedScrollView = scrollView - observation = scrollView.observe(\.contentOffset, options: [.new, .initial]) { [weak self, weak scrollView] _, change in - guard let self, let scrollView, let y = change.newValue?.y else { return } - let adjusted = max(0, y + scrollView.adjustedContentInset.top) - DispatchQueue.main.async { - self.onOffsetChanged?(adjusted) - } - } - } - - private func retryAttach() { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in - self?.attachIfNeeded() - } - } - - private func enclosingScrollView() -> UIScrollView? { - var current: UIView? = self - while let view = current { - if let scrollView = view as? UIScrollView { - return scrollView - } - current = view.superview - } - return nil - } - - private func findScrollViewInWindow() -> UIScrollView? { - guard let window else { return nil } - let targetPoint = convert(CGPoint(x: bounds.midX, y: bounds.midY), to: window) - return findScrollView(in: window, containing: targetPoint) - } - - private func findScrollView(in root: UIView, containing point: CGPoint) -> UIScrollView? { - for subview in root.subviews.reversed() { - if let match = findScrollView(in: subview, containing: point) { - return match - } - } - - if let scrollView = root as? UIScrollView { - let rectInWindow = scrollView.convert(scrollView.bounds, to: window) - if rectInWindow.contains(point) { - return scrollView - } - } - - return nil - } - - deinit { - observation?.invalidate() - } -} -#endif - struct AsyncStoreImage: View { let imageURL: String? diff --git a/pedi-foods/Sources/PediFoods/Views/Main/StoreDetailView+Logic.swift b/pedi-foods/Sources/PediFoods/Views/Main/StoreDetailView+Logic.swift index f510296..b0dba94 100644 --- a/pedi-foods/Sources/PediFoods/Views/Main/StoreDetailView+Logic.swift +++ b/pedi-foods/Sources/PediFoods/Views/Main/StoreDetailView+Logic.swift @@ -1,11 +1,15 @@ import SwiftUI extension StoreDetailView { - func heroIconButton(icon: String, action: @escaping () -> Void) -> some View { + func heroIconButton( + icon: String, + foregroundStyle: Color = .white, + action: @escaping () -> Void + ) -> some View { Button(action: action) { Image(systemName: icon) .font(.system(size: 14, weight: .semibold)) - .foregroundStyle(Color.white) + .foregroundStyle(foregroundStyle) .frame(width: 32, height: 32) .background(Color.white.opacity(0.24)) .clipShape(Circle()) @@ -65,6 +69,49 @@ extension StoreDetailView { info?.isOpen ?? true } + var isFavoriteStore: Bool { + appState.favorites.storeIds.contains(storeId) + } + + @MainActor + func toggleFavoriteStore() async { + guard isFavoriteRequestInFlight == false else { return } + guard appState.session.isAuthenticated else { + SnackbarCenter.shared.show(title: "Faça login para favoritar lojas.", style: .warning, icon: "person.crop.circle.badge.exclamationmark", duration: 2.5) + return + } + + let isFavorite = isFavoriteStore + isFavoriteRequestInFlight = true + defer { isFavoriteRequestInFlight = false } + + do { + let response = try await ApiService().setStoreFavorite(storeId: storeId, isFavorite: isFavorite == false) + guard response.error == false, let result = response.result else { + let message = response.message ?? "Não foi possível atualizar seus favoritos." + SnackbarCenter.shared.show(title: message, style: .error, icon: "xmark.octagon.fill", duration: 3.5) + return + } + + appState.favorites.storeIds = Set(result.favorites) + let successTitle = isFavorite + ? "\(storeName) removida dos favoritos." + : "\(storeName) adicionada aos favoritos." + let successIcon = isFavorite ? "heart.slash.fill" : "heart.fill" + SnackbarCenter.shared.show(title: successTitle, style: .success, icon: successIcon, duration: 2.2) + } catch { + let message: String + if let networkError = error as? NetworkError { + message = networkError.errorDescription ?? "Não foi possível atualizar seus favoritos." + } else if let serviceError = error as? ApiServiceError { + message = serviceError.errorDescription ?? "Não foi possível atualizar seus favoritos." + } else { + message = "Não foi possível atualizar seus favoritos." + } + SnackbarCenter.shared.show(title: message, style: .error, icon: "xmark.octagon.fill", duration: 3.5) + } + } + var summaryCardHeight: CGFloat { summaryCardBaseHeight + (isStoreOpen ? 0 : closedBannerHeight) } @@ -97,9 +144,13 @@ extension StoreDetailView { if forceRefresh == false, let cachedInfo: StoreInfoResult = AppContentCache.shared.value(for: infoCacheKey, as: StoreInfoResult.self), let cachedCatalog: [StoreCatalogCategory] = AppContentCache.shared.value(for: catalogCacheKey, as: [StoreCatalogCategory].self) { + let normalizedCatalog = StoreCatalogNormalizer.sanitize(categories: cachedCatalog, storeId: storeId) info = cachedInfo - categories = cachedCatalog - selectedCategoryId = cachedCatalog.first?.id + categories = normalizedCatalog + selectedCategoryId = StoreCatalogNormalizer.preferredCategoryId( + from: normalizedCatalog, + preferredId: selectedCategoryId + ) isLoading = false return } @@ -110,9 +161,9 @@ extension StoreDetailView { } do { - async let infoRequest = ApiService().storeInfo(storeId: storeId) - async let catalogRequest = ApiService().storeCatalog(storeId: storeId) - let (infoResponse, catalogResponse) = try await (infoRequest, catalogRequest) + let apiService = ApiService() + let infoResponse = try await apiService.storeInfo(storeId: storeId) + let catalogResponse = try await apiService.storeCatalog(storeId: storeId) if infoResponse.error { errorMessage = infoResponse.message ?? "Não foi possível carregar a loja." @@ -125,13 +176,21 @@ extension StoreDetailView { return } + let normalizedCatalog = StoreCatalogNormalizer.sanitize( + categories: catalogResponse.result ?? [], + storeId: storeId + ) + info = infoResponse.result - categories = catalogResponse.result ?? [] - selectedCategoryId = categories.first?.id + categories = normalizedCatalog + selectedCategoryId = StoreCatalogNormalizer.preferredCategoryId( + from: normalizedCatalog, + preferredId: selectedCategoryId + ) if let info = infoResponse.result { AppContentCache.shared.set(info, for: infoCacheKey, ttl: AppCacheTTL.twoHours) } - AppContentCache.shared.set(categories, for: catalogCacheKey, ttl: AppCacheTTL.twoHours) + AppContentCache.shared.set(normalizedCatalog, for: catalogCacheKey, ttl: AppCacheTTL.twoHours) isLoading = false } catch { isLoading = false diff --git a/pedi-foods/Sources/PediFoods/Views/Main/StoreDetailView.swift b/pedi-foods/Sources/PediFoods/Views/Main/StoreDetailView.swift index 4834975..df42bbb 100644 --- a/pedi-foods/Sources/PediFoods/Views/Main/StoreDetailView.swift +++ b/pedi-foods/Sources/PediFoods/Views/Main/StoreDetailView.swift @@ -34,6 +34,7 @@ struct StoreDetailView: View { @State var categoryHeaderOffsets: [String: CGFloat] = [:] @State var isProgrammaticCategoryScroll = false @State var scrollOffsetY: CGFloat = 0 + @State var isFavoriteRequestInFlight = false let cardTopInset: CGFloat = 168 let summaryCardBaseHeight: CGFloat = 170 @@ -52,6 +53,7 @@ struct StoreDetailView: View { ScrollView(showsIndicators: false) { #if os(Android) LazyVStack(spacing: 0) { + ScrollOffsetReader(offsetY: $scrollOffsetY) topSection categoryTabs(proxy: proxy, safeTop: safeTop, isSticky: false) @@ -60,6 +62,7 @@ struct StoreDetailView: View { } #else LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) { + ScrollOffsetReader(offsetY: $scrollOffsetY) topSection categoryTabs(proxy: proxy, safeTop: safeTop, isSticky: false) @@ -71,8 +74,8 @@ struct StoreDetailView: View { .refreshable { await loadStoreData(forceRefresh: true) } + .coordinateSpace(name: StoreDetailScrollCoordinateSpace.name) .ignoresSafeArea(edges: .top) - .background(ScrollOffsetReader(offsetY: $scrollOffsetY)) } .ignoresSafeArea(edges: .top) .overlay(alignment: .top) { @@ -218,7 +221,14 @@ struct StoreDetailView: View { } Spacer() heroIconButton(icon: "magnifyingglass") {} - heroIconButton(icon: "heart") {} + heroIconButton( + icon: isFavoriteStore ? "heart.fill" : "heart", + foregroundStyle: isFavoriteStore ? Color.red : Color.white + ) { + Task { + await toggleFavoriteStore() + } + } } .padding(.horizontal, 14) .padding(.top, UIDevice.topNotch) diff --git a/pedi-foods/Tests/PediFoodsTests/FavoritesDecodingTests.swift b/pedi-foods/Tests/PediFoodsTests/FavoritesDecodingTests.swift new file mode 100644 index 0000000..d91e743 --- /dev/null +++ b/pedi-foods/Tests/PediFoodsTests/FavoritesDecodingTests.swift @@ -0,0 +1,49 @@ +import Foundation +import Testing +@testable import PediFoods + +@Test("Customer profile decodes persisted favorite store ids") +func customerProfileDecodesFavoriteStoreIds() throws { + let json = """ + { + "error": false, + "result": { + "id": "cust_1", + "name": "Daniel", + "email": "daniel@example.com", + "phoneNumber": "+5511999999999", + "favorites": ["store_a", "store_b"], + "address_book": [] + } + } + """ + + let envelope = try JSONDecoder().decode(ApiEnvelope.self, from: Data(json.utf8)) + #expect(envelope.result?.favorites == ["store_a", "store_b"]) +} + +@Test("Favorite mutation decodes updated favorites array") +func favoriteMutationDecodesUpdatedFavoritesArray() throws { + let json = """ + { + "error": false, + "result": { + "favorites": ["store_a"], + "store": { + "id": "store_a", + "name": "CPS Drinks", + "category": "Doces & Bolos", + "rating": 4.8, + "totalReviews": 12, + "isOpen": true, + "statusLabel": "Aberto" + } + } + } + """ + + let envelope = try JSONDecoder().decode(ApiEnvelope.self, from: Data(json.utf8)) + #expect(envelope.result?.favorites == ["store_a"]) + #expect(envelope.result?.store?.id == "store_a") + #expect(envelope.result?.store?.name == "CPS Drinks") +} diff --git a/pedi-foods/Tests/PediFoodsTests/StoreCatalogNormalizerTests.swift b/pedi-foods/Tests/PediFoodsTests/StoreCatalogNormalizerTests.swift new file mode 100644 index 0000000..cc85638 --- /dev/null +++ b/pedi-foods/Tests/PediFoodsTests/StoreCatalogNormalizerTests.swift @@ -0,0 +1,117 @@ +import Testing +@testable import PediFoods + +@Test("Store catalog normalizer makes IDs non-empty and unique") +func storeCatalogNormalizerMakesIdsUnique() { + let catalog = [ + StoreCatalogCategory( + id: "", + name: "Pizzas", + isPizzaCategory: true, + pizzaConfig: StorePizzaConfig( + sizes: [ + StorePizzaSize(id: "", name: "Grande", slices: 8, maxFlavors: 2), + StorePizzaSize(id: "", name: "Familia", slices: 12, maxFlavors: 3) + ], + doughs: [ + StorePizzaDough(id: "massa", name: "Tradicional", active: true), + StorePizzaDough(id: "massa", name: "Fina", active: true) + ], + crusts: [ + StorePizzaCrust(id: "", name: "Cheddar", active: true, priceModifier: 5), + StorePizzaCrust(id: "", name: "Catupiry", active: true, priceModifier: 6) + ] + ), + products: [ + StoreCatalogProduct( + id: "", + type: "pizza", + name: "Calabresa", + description: nil, + image: nil, + price: 10, + originalPrice: nil, + pizzaPrices: [:], + addonGroups: [ + StoreAddonGroup( + id: "", + name: "Extras", + minSelectors: nil, + maxSelectors: nil, + items: [ + StoreAddonItem(id: "", name: "Bacon", price: 2), + StoreAddonItem(id: "", name: "Bacon em dobro", price: 4) + ] + ), + StoreAddonGroup( + id: "", + name: "Molhos", + minSelectors: nil, + maxSelectors: nil, + items: [ + StoreAddonItem(id: "", name: "Alho", price: 1) + ] + ) + ] + ), + StoreCatalogProduct( + id: "", + type: "pizza", + name: "Mussarela", + description: nil, + image: nil, + price: 12, + originalPrice: nil, + pizzaPrices: [:], + addonGroups: [] + ) + ] + ), + StoreCatalogCategory( + id: "", + name: "Bebidas", + isPizzaCategory: false, + pizzaConfig: nil, + products: [ + StoreCatalogProduct( + id: "", + type: nil, + name: "Refrigerante", + description: nil, + image: nil, + price: 7, + originalPrice: nil, + pizzaPrices: [:], + addonGroups: [] + ) + ] + ) + ] + + let normalized = StoreCatalogNormalizer.sanitize(categories: catalog, storeId: "store-1") + + let categoryIds = normalized.map(\.id) + #expect(Set(categoryIds).count == categoryIds.count) + #expect(categoryIds.allSatisfy { $0.isEmpty == false }) + + let firstCategory = normalized[0] + let productIds = firstCategory.products.map(\.id) + #expect(Set(productIds).count == productIds.count) + #expect(productIds.allSatisfy { $0.isEmpty == false }) + + let addonGroupIds = firstCategory.products[0].addonGroups.map(\.id) + #expect(Set(addonGroupIds).count == addonGroupIds.count) + #expect(addonGroupIds.allSatisfy { $0.isEmpty == false }) + + let addonItemIds = firstCategory.products[0].addonGroups.flatMap(\.items).map(\.id) + #expect(Set(addonItemIds).count == addonItemIds.count) + #expect(addonItemIds.allSatisfy { $0.isEmpty == false }) + + let sizeIds = firstCategory.pizzaConfig?.sizes.map(\.id) ?? [] + let doughIds = firstCategory.pizzaConfig?.doughs.map(\.id) ?? [] + let crustIds = firstCategory.pizzaConfig?.crusts.map(\.id) ?? [] + + #expect(Set(sizeIds).count == sizeIds.count) + #expect(Set(doughIds).count == doughIds.count) + #expect(Set(crustIds).count == crustIds.count) +} diff --git a/sim-home.png b/sim-home.png new file mode 100644 index 0000000000000000000000000000000000000000..742d33ff2fbb96e918f3d46b5447f6b21a4b8fd4 GIT binary patch literal 137541 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