From 3a42ff71e492a49a80143e27fc606b322266227d Mon Sep 17 00:00:00 2001 From: Jonas Jesus Date: Wed, 7 Oct 2026 02:56:58 -0300 Subject: [PATCH 1/3] feat(apps-nuvemshop): customer profile, addresses and orders loaders/actions Co-Authored-By: Claude Opus 5.5 (1M context) --- .../apps-nuvemshop/references/account.md | 42 ++++ .../src/__tests__/accountData.test.ts | 158 +++++++++++++ .../src/actions/account/addAddress.ts | 12 + .../src/actions/account/updateAddress.ts | 16 ++ .../src/actions/account/updateProfile.ts | 16 ++ packages/apps-nuvemshop/src/index.ts | 9 + .../src/loaders/account/addresses.ts | 12 + .../src/loaders/account/order.ts | 17 ++ .../src/loaders/account/orders.ts | 19 ++ .../src/loaders/account/profile.ts | 11 + packages/apps-nuvemshop/src/loaders/user.ts | 7 +- packages/apps-nuvemshop/src/manifest.gen.ts | 14 ++ packages/apps-nuvemshop/src/store.ts | 23 +- packages/apps-nuvemshop/src/utils/account.ts | 66 ++++++ .../apps-nuvemshop/src/utils/accountData.ts | 220 ++++++++++++++++++ packages/apps-nuvemshop/src/utils/orders.ts | 163 +++++++++++++ 16 files changed, 797 insertions(+), 8 deletions(-) create mode 100644 packages/apps-nuvemshop/src/__tests__/accountData.test.ts create mode 100644 packages/apps-nuvemshop/src/actions/account/addAddress.ts create mode 100644 packages/apps-nuvemshop/src/actions/account/updateAddress.ts create mode 100644 packages/apps-nuvemshop/src/actions/account/updateProfile.ts create mode 100644 packages/apps-nuvemshop/src/loaders/account/addresses.ts create mode 100644 packages/apps-nuvemshop/src/loaders/account/order.ts create mode 100644 packages/apps-nuvemshop/src/loaders/account/orders.ts create mode 100644 packages/apps-nuvemshop/src/loaders/account/profile.ts create mode 100644 packages/apps-nuvemshop/src/utils/account.ts create mode 100644 packages/apps-nuvemshop/src/utils/accountData.ts create mode 100644 packages/apps-nuvemshop/src/utils/orders.ts diff --git a/.agents/skills/apps-nuvemshop/references/account.md b/.agents/skills/apps-nuvemshop/references/account.md index b4d10818..5f1005f2 100644 --- a/.agents/skills/apps-nuvemshop/references/account.md +++ b/.agents/skills/apps-nuvemshop/references/account.md @@ -24,3 +24,45 @@ login) + `store_login_session`. `store.ts`: `adminToken` is a custom-app token, server-only, often created with full access. Never return it or Admin API payloads wholesale to the browser — `user` returns only id/name/email/phone of the session's own customer. + +## Customer data: profile, addresses, orders + +| Export | Upstream | Notes | +|---|---|---| +| `loaders/account/profile` | Admin `GET /customers/` | Picks id/name/email/phone/identification/`billing_*`. | +| `actions/account/updateProfile` | Admin `PUT /customers/` | Allow-list: name, phone, identification (CPF checked), `billing_*`. Never email/password/note. | +| `loaders/account/addresses` | Admin `GET /customers/?fields=addresses,default_address` | `default` flag derived. | +| `actions/account/addAddress` | Admin `PUT /customers/` `{addresses:[…]}` | The PUT **appends**. Country forced to BR. | +| `actions/account/updateAddress` | store form `POST /account/address//` (trailing slash required) | Admin PUT would create a duplicate. Success = 302 to `/account/addresses…`; rejected = 302 back to the form. | +| `loaders/account/orders` | Admin `GET /orders?customer_ids=` | `page` clamped 1..1000, `perPage` 1..50. Empty = 404 "Last page is 0" → `[]`. | +| `loaders/account/order` | Admin `GET /orders/?aggregates=fulfillment_orders` | | + +Code: `utils/accountData.ts` (operations), `utils/account.ts` (validation, `AccountError`, +pt-BR error mapping), `utils/orders.ts` (mapping + status labels). `sessionCustomerId()` +(`store.ts`, exported from the barrel) is the only source of the customer id. +All loaders are `cache = "no-store"` and go through `nuvemshopAdmin` (raw instrumented +transport, **not** `createFetchCache`): per-user data must never hit the shared GET cache. +Errors are `AccountError` (`.status`, pt-BR message); a site's server-fn layer should +surface only those and keep everything else generic. + +### Security rules (each is tested in `__tests__/accountData.test.ts`) + +- The customer id comes from the session only (`LS.customer` on `/account/`), never props. No session → 401, nothing sent to the Admin API. +- The scrape fails closed: every `LS.customer = N;` in the page must agree (user text rendered in the page can't override it). +- Ids from callers (`orderId`, `addressId`) go through `parseId` (digits only, safe integer > 0) before touching a URL. +- Ownership compares `String(a) === String(b)`; not-yours is the same 404 as not-found (same message, one upstream call). +- `orders` is post-filtered by `customer.id` regardless of the upstream filter; address updates only accept ids from the session customer's own list. +- Writes are allow-listed field by field; extra keys (`id`, `customer_id`, `email`) are dropped. +- **Same-origin / CSRF is the site's job** (server-fn layer): reject cross-origin browser requests (`Sec-Fetch-Site` not `same-origin`/`none`, or `Origin` host ≠ request host), set `Cache-Control: private, no-store`, and map errors. The package has no request-origin policy. + +### Unsupported (no upstream support) + +- Address delete / set default: neither the Admin API nor the store form exposes them. +- Logged-in password change: no endpoint. +- Password recovery: the store form needs a reCAPTCHA bound to the store domain. + +### Client bundles + +`admin.ts` carries no secrets itself (the token is read from server config at call time), but +the barrel (`index.ts`) re-exports server-only code (`store.ts`, loaders). Import loaders/actions +from server code (invoke/server fns) — never from client components. diff --git a/packages/apps-nuvemshop/src/__tests__/accountData.test.ts b/packages/apps-nuvemshop/src/__tests__/accountData.test.ts new file mode 100644 index 00000000..0418782a --- /dev/null +++ b/packages/apps-nuvemshop/src/__tests__/accountData.test.ts @@ -0,0 +1,158 @@ +// Regression tests for "no user sees or changes another user's data". No network: fetch is mocked. +import { RequestContext } from "@decocms/blocks/sdk/requestContext"; +import { describe, expect, it } from "vitest"; +import addAddress from "../actions/account/addAddress"; +import updateAddress from "../actions/account/updateAddress"; +import updateProfile from "../actions/account/updateProfile"; +import { configureNuvemshop, setNuvemshopFetch } from "../client"; +import order from "../loaders/account/order"; +import orders from "../loaders/account/orders"; +import profile from "../loaders/account/profile"; +import { sessionCustomerId } from "../store"; +import { parseId } from "../utils/account"; + +const json = (b: unknown, status = 200) => new Response(JSON.stringify(b), { status }); +const calls: { url: string; init?: RequestInit }[] = []; +const isAccountPage = (u: string) => u === "https://s.example/account/"; + +/** Mocks upstream; the store's /account/ page reports `session` as the logged-in customer. */ +function mock( + handler: (url: string, init?: RequestInit) => Response, + session = "LS.customer = 7;", +) { + calls.length = 0; + configureNuvemshop({ storeId: "1", adminToken: "t", storeUrl: "https://s.example" }); + setNuvemshopFetch((async (u: unknown, i?: RequestInit) => { + calls.push({ url: String(u), init: i }); + return isAccountPage(String(u)) ? new Response(session) : handler(String(u), i); + }) as typeof fetch); +} +const as = (fn: () => Promise, cookie: string | null = "store_x=1") => + RequestContext.run(new Request("https://x.example/", cookie ? { headers: { cookie } } : {}), fn); +const msg = (p: Promise) => + p.then( + () => "OK", + (e: { status: number; message: string }) => `${e.status} ${e.message}`, + ); +const upstream = () => calls.filter((c) => !isAccountPage(c.url)); +const ADDR = { + address: "R", + number: "1", + locality: "B", + city: "C", + province: "SP", + zipcode: "01001000", +}; + +describe("ownership", () => { + it("foreign order == nonexistent order (same status and message)", async () => { + mock((u) => (u.includes("/orders/1") ? json({ id: 1, customer: { id: 8 } }) : json({}, 404))); + const foreign = await as(() => msg(order({ orderId: 1 }))); + expect(foreign).toBe("404 Pedido não encontrado."); + expect(await as(() => msg(order({ orderId: 2 })))).toBe(foreign); + expect(await as(() => msg(order({ orderId: 0 })))).toBe(foreign); + }); + + it("compares ids as strings (upstream string id still matches only the owner)", async () => { + mock(() => json({ id: 1, customer: { id: "7" } })); + expect(await as(() => msg(order({ orderId: 1 })))).toBe("OK"); + mock(() => json({ id: 1, customer: { id: "7" } }), "LS.customer = 70;"); + expect(await as(() => msg(order({ orderId: 1 })))).toBe("404 Pedido não encontrado."); + }); + + it("path/query injection ids never reach the Admin API", async () => { + mock(() => json({ id: 1, customer: { id: 7 } })); + for (const bad of [ + "1,2", + "../customers/8", + "123?customer_ids=8", + "1e3", + " 1", + "-1", + 1.5, + Number.NaN, + null, + {}, + 1e21, + ]) { + expect(parseId(bad)).toBeNull(); + expect(await as(() => msg(order({ orderId: bad as number })))).toBe( + "404 Pedido não encontrado.", + ); + } + expect(upstream()).toHaveLength(0); + expect(parseId("42")).toBe(42); + }); + + it("orders sends the session id, clamps paging and drops foreign orders", async () => { + mock(() => json([{ id: 1, customer: { id: 7 } }, { id: 2, customer: { id: 8 } }, { id: 3 }])); + const r = await as(() => orders({ page: 99999, perPage: 9999 })); + expect(r.map((o) => o.id)).toEqual([1]); + const q = new URL(upstream()[0].url).searchParams; + expect([q.get("customer_ids"), q.get("per_page"), q.get("page")]).toEqual(["7", "50", "1000"]); + }); + + it("orders: empty page (404 Last page) is []", async () => { + mock(() => json({ description: "Last page is 0" }, 404)); + expect(await as(() => orders({}))).toEqual([]); + }); + + it("profile/updateProfile target the session customer and allow-list fields", async () => { + mock(() => json({ id: 7, name: "A", email: "e" })); + await as(() => profile({})); + expect(upstream()[0].url).toContain("/customers/7"); + await as(() => updateProfile({ name: "A", id: 8, customer_id: 8, email: "x@y.z" } as never)); + const put = upstream()[1]; + expect(put.url).toContain("/customers/7"); + expect(JSON.parse(String(put.init?.body))).toEqual({ name: "A" }); + }); + + it("addAddress writes to the session customer only", async () => { + mock(() => json({ addresses: [{ id: 10 }] })); + await as(() => addAddress({ ...ADDR, customer_id: 8 } as never)); + expect(upstream()[0].url).toContain("/customers/7"); + expect(JSON.parse(String(upstream()[0].init?.body))).not.toHaveProperty("customer_id"); + }); + + it("updateAddress with a foreign address id is rejected before any write", async () => { + mock(() => json({ addresses: [{ id: 10 }] })); + expect(await as(() => msg(updateAddress({ ...ADDR, addressId: 11 })))).toBe( + "404 Endereço não encontrado.", + ); + expect(await as(() => msg(updateAddress({ ...ADDR, addressId: "10/../11" })))).toBe( + "404 Endereço não encontrado.", + ); + expect(calls.every((c) => c.init?.method !== "POST")).toBe(true); + }); +}); + +describe("session", () => { + it("logged out -> 401 on every entry point, nothing reaches the Admin API", async () => { + mock(() => json({}), "LS.customer = false;"); + for (const run of [ + () => profile({}), + () => orders({}), + () => order({ orderId: 1 }), + () => addAddress(ADDR), + ]) { + expect(await as(() => msg(run()))).toBe("401 Faça login para continuar."); + expect(await as(() => msg(run()), null)).toBe("401 Faça login para continuar."); + } + expect(upstream()).toHaveLength(0); + }); + + it("conflicting LS.customer values fail closed; single value works; non-store cookies are not forwarded", async () => { + mock(() => json({}), "LS.customer = 7;\nOlá LS.customer = 8; "); + expect(await as(() => sessionCustomerId())).toBeNull(); + mock(() => json({}), "LS.customer = 7;\nLS.customerHasPriceTables = false;"); + expect(await as(() => sessionCustomerId(), "a=1; store_x=1")).toBe(7); + expect((calls[0].init!.headers as Record).cookie).toBe("store_x=1"); + }); + + it("redirected /account/ (expired session) -> null", async () => { + calls.length = 0; + configureNuvemshop({ storeId: "1", adminToken: "t", storeUrl: "https://s.example" }); + setNuvemshopFetch((async () => new Response("", { status: 302 })) as typeof fetch); + expect(await as(() => sessionCustomerId())).toBeNull(); + }); +}); diff --git a/packages/apps-nuvemshop/src/actions/account/addAddress.ts b/packages/apps-nuvemshop/src/actions/account/addAddress.ts new file mode 100644 index 00000000..17087e6c --- /dev/null +++ b/packages/apps-nuvemshop/src/actions/account/addAddress.ts @@ -0,0 +1,12 @@ +import { type AddressInput, addAddress, requireCustomerId } from "../../utils/accountData"; +import type { Address } from "../../utils/orders"; + +export type Props = AddressInput; + +/** + * @title Nuvemshop - Add address + * @description Adds an address (Brazil) to the logged-in buyer. + */ +export default async function addAddressAction(props: Props): Promise
{ + return addAddress(await requireCustomerId(), props); +} diff --git a/packages/apps-nuvemshop/src/actions/account/updateAddress.ts b/packages/apps-nuvemshop/src/actions/account/updateAddress.ts new file mode 100644 index 00000000..813dc5b3 --- /dev/null +++ b/packages/apps-nuvemshop/src/actions/account/updateAddress.ts @@ -0,0 +1,16 @@ +import { type AddressInput, requireCustomerId, updateAddress } from "../../utils/accountData"; + +export interface Props extends AddressInput { + /** @title Address id */ + addressId: number | string; +} + +/** + * @title Nuvemshop - Update address + * @description Updates one of the logged-in buyer's addresses through the store form. A foreign address id is 404. + */ +export default async function updateAddressAction(props: Props): Promise<{ ok: true }> { + const { addressId, ...input } = props ?? ({} as Props); + await updateAddress(await requireCustomerId(), addressId, input as AddressInput); + return { ok: true }; +} diff --git a/packages/apps-nuvemshop/src/actions/account/updateProfile.ts b/packages/apps-nuvemshop/src/actions/account/updateProfile.ts new file mode 100644 index 00000000..12bcd8ea --- /dev/null +++ b/packages/apps-nuvemshop/src/actions/account/updateProfile.ts @@ -0,0 +1,16 @@ +import { + type Profile, + type ProfileInput, + requireCustomerId, + updateProfile, +} from "../../utils/accountData"; + +export type Props = ProfileInput; + +/** + * @title Nuvemshop - Update profile + * @description Updates the logged-in buyer's profile. Only allow-listed fields (name, phone, CPF, billing_*) are sent; email/password are not editable. + */ +export default async function updateProfileAction(props: Props): Promise { + return updateProfile(await requireCustomerId(), props); +} diff --git a/packages/apps-nuvemshop/src/index.ts b/packages/apps-nuvemshop/src/index.ts index 2763210a..5c9ad38a 100644 --- a/packages/apps-nuvemshop/src/index.ts +++ b/packages/apps-nuvemshop/src/index.ts @@ -1,6 +1,9 @@ +export { default as addAddress } from "./actions/account/addAddress"; export { default as login } from "./actions/account/login"; export { default as logout } from "./actions/account/logout"; export { default as register } from "./actions/account/register"; +export { default as updateAddress } from "./actions/account/updateAddress"; +export { default as updateProfile } from "./actions/account/updateProfile"; export { default as createCheckout } from "./actions/createCheckout"; export { clearNuvemshopCache, @@ -13,6 +16,10 @@ export { PRODUCT_FIELDS, setNuvemshopFetch, } from "./client"; +export { default as addresses } from "./loaders/account/addresses"; +export { default as order } from "./loaders/account/order"; +export { default as orders } from "./loaders/account/orders"; +export { default as profile } from "./loaders/account/profile"; export { default as cart } from "./loaders/cart"; export { default as categories } from "./loaders/categories"; export { default as productDetailsPage } from "./loaders/productDetailsPage"; @@ -24,6 +31,8 @@ export { default as suggestions } from "./loaders/suggestions"; export { default as user } from "./loaders/user"; export { configure } from "./mod"; export { NUVEMSHOP_REGISTRY_ENTRY } from "./registry"; +export { sessionCustomerId } from "./store"; +export { AccountError } from "./utils/account"; export { createNuvemshopFetch } from "./utils/instrumentedFetch"; export { nuvemshopOperationRouter } from "./utils/operationRouter"; export { nuvemshopSitemap } from "./utils/sitemap"; diff --git a/packages/apps-nuvemshop/src/loaders/account/addresses.ts b/packages/apps-nuvemshop/src/loaders/account/addresses.ts new file mode 100644 index 00000000..e3b5ff92 --- /dev/null +++ b/packages/apps-nuvemshop/src/loaders/account/addresses.ts @@ -0,0 +1,12 @@ +import { listAddresses, requireCustomerId } from "../../utils/accountData"; +import type { Address } from "../../utils/orders"; + +/** + * @title Nuvemshop - Customer addresses + * @description The logged-in buyer's saved addresses (id taken from the store session). + */ +export default async function addresses(_props: unknown): Promise { + return listAddresses(await requireCustomerId()); +} + +export const cache = "no-store"; diff --git a/packages/apps-nuvemshop/src/loaders/account/order.ts b/packages/apps-nuvemshop/src/loaders/account/order.ts new file mode 100644 index 00000000..aea98aa9 --- /dev/null +++ b/packages/apps-nuvemshop/src/loaders/account/order.ts @@ -0,0 +1,17 @@ +import { getOrder, requireCustomerId } from "../../utils/accountData"; +import type { OrderDetail } from "../../utils/orders"; + +export interface Props { + /** @title Order id */ + orderId: number | string; +} + +/** + * @title Nuvemshop - Customer order + * @description One order of the logged-in buyer. Not-yours is indistinguishable from not-found (404). + */ +export default async function order(props: Props): Promise { + return getOrder(await requireCustomerId(), props?.orderId); +} + +export const cache = "no-store"; diff --git a/packages/apps-nuvemshop/src/loaders/account/orders.ts b/packages/apps-nuvemshop/src/loaders/account/orders.ts new file mode 100644 index 00000000..3186aa52 --- /dev/null +++ b/packages/apps-nuvemshop/src/loaders/account/orders.ts @@ -0,0 +1,19 @@ +import { listOrders, requireCustomerId } from "../../utils/accountData"; +import type { Order } from "../../utils/orders"; + +export interface Props { + /** @title Page */ + page?: number; + /** @title Orders per page (max 50) */ + perPage?: number; +} + +/** + * @title Nuvemshop - Customer orders + * @description The logged-in buyer's orders, newest first. Only orders owned by the session customer are returned. + */ +export default async function orders(props: Props): Promise { + return listOrders(await requireCustomerId(), props ?? {}); +} + +export const cache = "no-store"; diff --git a/packages/apps-nuvemshop/src/loaders/account/profile.ts b/packages/apps-nuvemshop/src/loaders/account/profile.ts new file mode 100644 index 00000000..a7f8dfcb --- /dev/null +++ b/packages/apps-nuvemshop/src/loaders/account/profile.ts @@ -0,0 +1,11 @@ +import { getProfile, type Profile, requireCustomerId } from "../../utils/accountData"; + +/** + * @title Nuvemshop - Customer profile + * @description The logged-in buyer's profile (id taken from the store session; 401 when logged out). + */ +export default async function profile(_props: unknown): Promise { + return getProfile(await requireCustomerId()); +} + +export const cache = "no-store"; diff --git a/packages/apps-nuvemshop/src/loaders/user.ts b/packages/apps-nuvemshop/src/loaders/user.ts index 0eea9c2b..eccd29ca 100644 --- a/packages/apps-nuvemshop/src/loaders/user.ts +++ b/packages/apps-nuvemshop/src/loaders/user.ts @@ -1,6 +1,6 @@ import { nuvemshopAdmin } from "../admin"; import { getNuvemshopConfig } from "../client"; -import { storeCookies, storeFetch } from "../store"; +import { sessionCustomerId } from "../store"; export interface NuvemshopUser { id: number; @@ -15,10 +15,7 @@ export interface NuvemshopUser { * Reads the customer id from the store's account page and the profile from the Admin API. */ export default async function user(_props: unknown): Promise { - if (!storeCookies()) return null; - const page = await storeFetch("/account/"); - if (page.status !== 200) return null; - const id = Number((await page.text()).match(/LS\.customer\s*=\s*(\d+)/)?.[1]); + const id = await sessionCustomerId(); if (!id) return null; if (!getNuvemshopConfig().adminToken) return { id }; const res = await nuvemshopAdmin(`/customers/${id}`); diff --git a/packages/apps-nuvemshop/src/manifest.gen.ts b/packages/apps-nuvemshop/src/manifest.gen.ts index 0cb13b0b..ac90d9e5 100644 --- a/packages/apps-nuvemshop/src/manifest.gen.ts +++ b/packages/apps-nuvemshop/src/manifest.gen.ts @@ -1,10 +1,17 @@ // Hand-maintained (the generate-manifests script referenced by other apps doesn't exist). // Keys are the `__resolveType` / `/deco/invoke/` names. +import * as actions_account_addAddress from "./actions/account/addAddress"; import * as actions_account_login from "./actions/account/login"; import * as actions_account_logout from "./actions/account/logout"; import * as actions_account_register from "./actions/account/register"; +import * as actions_account_updateAddress from "./actions/account/updateAddress"; +import * as actions_account_updateProfile from "./actions/account/updateProfile"; import * as actions_createCheckout from "./actions/createCheckout"; +import * as loaders_account_addresses from "./loaders/account/addresses"; +import * as loaders_account_order from "./loaders/account/order"; +import * as loaders_account_orders from "./loaders/account/orders"; +import * as loaders_account_profile from "./loaders/account/profile"; import * as loaders_cart from "./loaders/cart"; import * as loaders_categories from "./loaders/categories"; import * as loaders_productDetailsPage from "./loaders/productDetailsPage"; @@ -18,6 +25,10 @@ import * as loaders_user from "./loaders/user"; const manifest = { name: "nuvemshop", loaders: { + "nuvemshop/loaders/account/profile": loaders_account_profile, + "nuvemshop/loaders/account/addresses": loaders_account_addresses, + "nuvemshop/loaders/account/orders": loaders_account_orders, + "nuvemshop/loaders/account/order": loaders_account_order, "nuvemshop/loaders/cart": loaders_cart, "nuvemshop/loaders/categories": loaders_categories, "nuvemshop/loaders/productDetailsPage": loaders_productDetailsPage, @@ -29,6 +40,9 @@ const manifest = { "nuvemshop/loaders/user": loaders_user, }, actions: { + "nuvemshop/actions/account/updateProfile": actions_account_updateProfile, + "nuvemshop/actions/account/addAddress": actions_account_addAddress, + "nuvemshop/actions/account/updateAddress": actions_account_updateAddress, "nuvemshop/actions/account/login": actions_account_login, "nuvemshop/actions/account/logout": actions_account_logout, "nuvemshop/actions/account/register": actions_account_register, diff --git a/packages/apps-nuvemshop/src/store.ts b/packages/apps-nuvemshop/src/store.ts index b76557a5..89cf8260 100644 --- a/packages/apps-nuvemshop/src/store.ts +++ b/packages/apps-nuvemshop/src/store.ts @@ -13,14 +13,14 @@ import { getNuvemshopConfig, nuvemshopFetch } from "./client"; const STORE_COOKIE = /^store_/; -export function storeOrigin(): string { +function storeOrigin(): string { const { storeUrl } = getNuvemshopConfig(); if (!storeUrl) throw new Error("Nuvemshop storeUrl is not configured (the store's own domain)"); return new URL(storeUrl).origin; } /** `store_*` cookies of the current request, as a Cookie header ("" when none). */ -export function storeCookies(): string { +function storeCookies(): string { const raw = RequestContext.current?.request.headers.get("cookie") ?? ""; return raw .split(/;\s*/) @@ -45,7 +45,7 @@ function mergeCookies(header: string, setCookies: string[]) { return [...jar.values()].join("; "); } -export interface StoreResponse { +interface StoreResponse { status: number; location: string | null; text: () => Promise; @@ -86,3 +86,20 @@ export async function storeFetch( cookie: mergeCookies(cookie, setCookies), }; } + +/** + * The logged-in customer id, from the store session (`LS.customer` on /account/); + * null when logged out. The ONLY source of a customer id for account loaders/actions — + * never take one from props. Fails closed: every `LS.customer = N;` in the page must + * agree, so user-controlled text rendered in the page can't override it. + */ +export async function sessionCustomerId(): Promise { + if (!storeCookies()) return null; + const page = await storeFetch("/account/"); + if (page.status !== 200) return null; + const ids = new Set( + [...(await page.text()).matchAll(/LS\.customer\s*=\s*(\d+)\s*;/g)].map((m) => m[1]), + ); + const id = ids.size === 1 ? Number([...ids][0]) : 0; + return Number.isSafeInteger(id) && id > 0 ? id : null; +} diff --git a/packages/apps-nuvemshop/src/utils/account.ts b/packages/apps-nuvemshop/src/utils/account.ts new file mode 100644 index 00000000..76d9b4eb --- /dev/null +++ b/packages/apps-nuvemshop/src/utils/account.ts @@ -0,0 +1,66 @@ +/** Server-side validation (Nuvemshop validates none of this) and pt-BR error mapping. */ + +export class AccountError extends Error { + constructor( + message: string, + readonly status = 400, + ) { + super(message); + } +} + +/** Positive integer id from untrusted input (number or plain digit string), else null. "1,2", "12/x", "1e3", " 1" -> null. */ +export function parseId(v: unknown): number | null { + const n = + typeof v === "number" ? v : typeof v === "string" && /^\d{1,15}$/.test(v) ? Number(v) : NaN; + return Number.isSafeInteger(n) && n > 0 ? n : null; +} + +/** CPF with check digits. ponytail: CPF only, no CNPJ; add when business accounts are needed. */ +export function isValidCpf(input: string): boolean { + const d = input.replace(/\D/g, ""); + if (d.length !== 11 || /^(\d)\1+$/.test(d)) return false; + const dv = (n: number) => { + let s = 0; + for (let i = 0; i < n; i++) s += Number(d[i]) * (n + 1 - i); + return ((s * 10) % 11) % 10; + }; + return dv(9) === Number(d[9]) && dv(10) === Number(d[10]); +} + +/** Brazilian phone (DDD + 8/9 digits, optional +55) -> "+55DDDNNNNNNNNN", or null if invalid. */ +export function normalizePhone(input: string): string | null { + let d = input.replace(/\D/g, ""); + if (d.length > 11 && d.startsWith("55")) d = d.slice(2); + if (d.length < 10 || d.length > 11 || d[0] === "0" || d[1] === "0") return null; + if (d.length === 11 && d[2] !== "9") return null; + return `+55${d}`; +} + +const FIELD_LABELS: Record = { + name: "nome", + email: "e-mail", + phone: "telefone", + identification: "CPF", + address: "endereço", + number: "número", + locality: "bairro", + city: "cidade", + province: "estado", + zipcode: "CEP", +}; + +/** Admin 422 `{field: [msg]}` -> pt-BR. */ +export function mapAdminError(status: number, body: unknown): AccountError { + if (status === 401 || status === 403) return new AccountError("Acesso negado.", 403); + if (status === 404) return new AccountError("Não encontrado.", 404); + if (status === 429) + return new AccountError("Muitas tentativas. Tente novamente em instantes.", 429); + if (status === 422 && body && typeof body === "object") { + const fields = Object.keys(body) + .map((k) => FIELD_LABELS[k.replace(/^billing_/, "")] ?? k) + .join(", "); + return new AccountError(`Confira os dados informados (${fields}).`, 422); + } + return new AccountError("Não foi possível concluir agora. Tente novamente.", 502); +} diff --git a/packages/apps-nuvemshop/src/utils/accountData.ts b/packages/apps-nuvemshop/src/utils/accountData.ts new file mode 100644 index 00000000..b6205e98 --- /dev/null +++ b/packages/apps-nuvemshop/src/utils/accountData.ts @@ -0,0 +1,220 @@ +/** + * Customer data operations. Every function takes the SESSION customer id + * (`sessionCustomerId()`), never client input. Per-user data: never cached + * (`nuvemshopAdmin` goes through the raw transport, not the shared GET cache). + */ +import { nuvemshopAdmin } from "../admin"; +import { sessionCustomerId, storeFetch } from "../store"; +import { AccountError, isValidCpf, mapAdminError, normalizePhone, parseId } from "./account"; +import { type Address, mapOrder, mapOrderDetail, type Order, type OrderDetail } from "./orders"; + +const BILLING = [ + "name", + "phone", + "address", + "number", + "floor", + "locality", + "zipcode", + "city", + "province", + "country", +] as const; +type BillingKey = `billing_${(typeof BILLING)[number]}`; + +export interface Profile extends Partial> { + id: number; + name: string; + email: string; + phone: string | null; + identification: string | null; +} +export type ProfileInput = Partial>; + +type Raw = any; + +async function admin(path: string, init?: { method?: string; body?: unknown }): Promise { + const res = await nuvemshopAdmin(path, init); + if (res.ok) return res.json(); + throw mapAdminError(res.status, await res.json().catch(() => null)); +} + +const pickProfile = (c: Raw): Profile => ({ + id: c.id, + name: c.name, + email: c.email, + phone: c.phone ?? null, + identification: c.identification ?? null, + ...Object.fromEntries(BILLING.map((k) => [`billing_${k}`, c[`billing_${k}`] ?? null])), +}); + +export const getProfile = async (customerId: number): Promise => + pickProfile(await admin(`/customers/${customerId}`)); + +const clean = (v: unknown) => (typeof v === "string" ? v.trim() : ""); + +export async function updateProfile(customerId: number, input: ProfileInput): Promise { + if (!input || typeof input !== "object") throw new AccountError("Confira os dados informados."); + const body: Record = {}; + // Allow-list: only these keys ever reach the Admin API (no email/note/password/extra). + if (input.name !== undefined) { + if (!clean(input.name)) throw new AccountError("Informe seu nome."); + body.name = clean(input.name); + } + if (input.identification !== undefined) { + const v = clean(input.identification); + if (v && !isValidCpf(v)) throw new AccountError("CPF inválido."); + body.identification = v ? v.replace(/\D/g, "") : null; + } + for (const k of ["phone", "billing_phone"] as const) { + if (input[k] === undefined) continue; + const v = clean(input[k]); + const p = v && normalizePhone(v); + if (v && !p) throw new AccountError("Telefone inválido. Use DDD + número."); + body[k] = p || null; + } + for (const k of BILLING) { + const key = `billing_${k}` as BillingKey; + if (key === "billing_phone" || input[key] === undefined) continue; + // billing_country can't be null, "" clears it. + body[key] = clean(input[key]) || (k === "country" ? "" : null); + } + if (!Object.keys(body).length) return getProfile(customerId); + return pickProfile(await admin(`/customers/${customerId}`, { method: "PUT", body })); +} + +/* -------------------------------- addresses -------------------------------- */ + +export interface AddressInput { + name?: string; + address: string; + number: string; + floor?: string; + locality: string; + city: string; + province: string; + zipcode: string; + phone?: string; +} + +function validateAddress(a: AddressInput) { + if (!a || typeof a !== "object") + throw new AccountError("Preencha todos os campos obrigatórios do endereço."); + const out = { + name: clean(a.name), + address: clean(a.address), + number: clean(a.number), + floor: clean(a.floor), + locality: clean(a.locality), + city: clean(a.city), + province: clean(a.province), + zipcode: clean(a.zipcode).replace(/\D/g, ""), + phone: clean(a.phone), + }; + for (const k of ["address", "number", "locality", "city", "province"] as const) + if (!out[k]) throw new AccountError("Preencha todos os campos obrigatórios do endereço."); + if (out.zipcode.length !== 8) throw new AccountError("CEP inválido."); + if (out.phone) { + const p = normalizePhone(out.phone); + if (!p) throw new AccountError("Telefone inválido. Use DDD + número."); + out.phone = p; + } + return out; +} + +export async function listAddresses(customerId: number): Promise { + const c = await admin(`/customers/${customerId}?fields=addresses,default_address`); + const list: Address[] = c.addresses ?? []; + const def = c.default_address?.id ?? list.find((a) => a.default)?.id; + return list.map((a) => ({ ...a, default: a.id === def })); +} + +/** Admin PUT with `addresses` appends (existing ones are kept); response lists only the new one. */ +export async function addAddress(customerId: number, input: AddressInput): Promise
{ + const { name: _name, ...a } = validateAddress(input); // Admin ignores `name` + const c = await admin(`/customers/${customerId}`, { + method: "PUT", + body: { addresses: [{ ...a, country: "BR" }] }, + }); + return c.addresses?.[0]; +} + +/** Brasil in the store's country