diff --git a/app/api/customer.py b/app/api/customer.py
index f3f22e7..0bccef5 100644
--- a/app/api/customer.py
+++ b/app/api/customer.py
@@ -2,7 +2,9 @@
from datetime import datetime, timedelta, timezone
from uuid import UUID, uuid4
-from fastapi import APIRouter, Depends, HTTPException, Request, Response
+from typing import Literal
+
+from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
from psycopg.errors import UniqueViolation
from psycopg.types.json import Jsonb
@@ -10,7 +12,7 @@ from ..core import db
from ..artwork import submit_files
from ..core.auth import (DUMMY_PASSWORD_HASH, audit, client_ip, new_session, owner,
login_failed, password_hash, password_matches, session_row, throttle, transfer_guest)
-from ..core.models import ArtworkSubmission, Login, Register
+from ..core.models import ArtworkSubmission, EmailChange, Login, PasswordChange, ProfileUpdate, Register
from ..runtime import STATES, file_rows, owned_order, storage
from ..scanning import require_clean
@@ -86,13 +88,112 @@ def me(identity=Depends(owner)):
row = c.execute('SELECT profile FROM dtf_local.accounts WHERE id=%s', (identity,)).fetchone()
return {'customer': row['profile'] if row else None}
-@router.get('/api/customer/orders')
-def orders(identity=Depends(owner)):
+def account_row(c, identity, lock=False):
+ row = c.execute('SELECT * FROM dtf_local.accounts WHERE id=%s' + (' FOR UPDATE' if lock else ''), (identity,)).fetchone()
+ if not row:
+ raise HTTPException(401, 'Entre na sua conta.')
+ return row
+
+@router.post('/api/account/profile')
+def update_profile(body: ProfileUpdate, identity=Depends(owner)):
+ """WhatsApp and the saved delivery address. The CNPJ is the account's and
+ does not change; the e-mail is the login and changes on its own route."""
with db.connect() as c:
- rows = c.execute('SELECT id,number,state,version,snapshot,created_at,updated_at FROM dtf_local.orders WHERE owner=%s ORDER BY created_at DESC', (identity,)).fetchall()
- quotes = c.execute('''SELECT q.id,q.approved,q.approved_at,q.created_at FROM dtf_local.quotes q
- LEFT JOIN dtf_local.orders o ON o.quote_id=q.id WHERE q.owner=%s AND o.id IS NULL ORDER BY q.created_at DESC''', (identity,)).fetchall()
- return {'orders': rows, 'quotes': quotes, 'states': STATES}
+ row = account_row(c, identity, lock=True)
+ profile = {**row['profile'], 'zap': body.zap,
+ 'address': body.address.model_dump() if body.address else None}
+ c.execute('UPDATE dtf_local.accounts SET profile=%s WHERE id=%s', (Jsonb(profile), identity))
+ audit('account_profile_updated', account=str(identity))
+ return {'customer': profile}
+
+@router.post('/api/account/email')
+def change_email(body: EmailChange, request: Request, identity=Depends(owner)):
+ with db.connect() as c:
+ row = account_row(c, identity)
+ throttle(row['email'], request)
+ if not password_matches(body.password, row['password_hash']):
+ login_failed(row['email'])
+ raise HTTPException(401, 'Senha incorreta.')
+ profile = {**row['profile'], 'mail': body.email}
+ try:
+ with db.connect() as c:
+ c.execute('UPDATE dtf_local.accounts SET email=%s, profile=%s WHERE id=%s', (body.email, Jsonb(profile), identity))
+ except UniqueViolation:
+ raise HTTPException(409, 'Este e-mail já é de outra conta.')
+ audit('account_email_changed', account=str(identity))
+ return {'customer': profile}
+
+@router.post('/api/account/password')
+def change_password(body: PasswordChange, request: Request, identity=Depends(owner)):
+ with db.connect() as c:
+ row = account_row(c, identity)
+ throttle(row['email'], request)
+ if not password_matches(body.current, row['password_hash']):
+ login_failed(row['email'])
+ raise HTTPException(401, 'Senha atual incorreta.')
+ session = current(request)
+ with db.connect() as c:
+ c.execute('UPDATE dtf_local.accounts SET password_hash=%s WHERE id=%s', (password_hash(body.new), identity))
+ # Other devices signed in with the old password are signed out.
+ c.execute('DELETE FROM dtf_local.sessions WHERE owner=%s AND id<>%s', (identity, session['id']))
+ audit('account_password_changed', account=str(identity))
+ return {'ok': True}
+
+# The order list's groups, as the customer filters them.
+GROUPS = {'pay': 'Aguardando pagamento', 'prod': 'Em produção', 'cor': 'Correção', 'fin': 'Finalizados'}
+ENTRIES = '''WITH live_quote AS (
+ -- The cart waiting for payment: the newest unpaid quote whose files still
+ -- exist. An older one was replaced when the cart changed, and one whose
+ -- files are gone can no longer be paid.
+ SELECT q.id, q.created_at, q.approved FROM dtf_local.quotes q
+ LEFT JOIN dtf_local.orders o ON o.quote_id=q.id
+ WHERE q.owner=%(me)s AND o.id IS NULL
+ AND NOT EXISTS (SELECT 1 FROM jsonb_array_elements(q.draft->'items') i
+ CROSS JOIN LATERAL jsonb_array_elements_text(i->'uploads') u(uid)
+ JOIN dtf_local.uploads up ON up.id=u.uid::uuid
+ WHERE up.purged_at IS NOT NULL OR up.expires_at<=now())
+ ORDER BY q.created_at DESC LIMIT 1
+), entries AS (
+ SELECT 'order' AS kind, o.id, o.number, o.state,
+ CASE WHEN o.state='fin' THEN 'fin' WHEN o.state='cor' THEN 'cor' ELSE 'prod' END AS grp,
+ (o.snapshot->>'total_cents')::bigint AS total_cents, o.created_at, o.snapshot->'items' AS items
+ FROM dtf_local.orders o WHERE o.owner=%(me)s
+ UNION ALL
+ SELECT 'quote', q.id, NULL, CASE WHEN q.approved IS NULL THEN 'review' ELSE 'pay' END, 'pay',
+ (q.approved->>'total_cents')::bigint, q.created_at, COALESCE(q.approved->'items','[]'::jsonb)
+ FROM live_quote q
+)'''
+
+@router.get('/api/customer/orders')
+def orders(identity=Depends(owner), status: Literal['all','pay','prod','cor','fin'] = 'all',
+ number: int | None = Query(None, ge=1), days: int = Query(0, ge=0, le=3650),
+ page: int = Query(1, ge=1, le=10000), size: int = Query(10, ge=1, le=50)):
+ """The customer's orders and the cart waiting for payment, newest first,
+ filtered and a page at a time, with how many there are in each group."""
+ params = {'me': identity, 'grp': None if status == 'all' else status, 'number': number,
+ 'since': datetime.now(timezone.utc) - timedelta(days=days) if days else None,
+ 'size': size, 'offset': (page - 1) * size}
+ where = '''WHERE (%(grp)s::text IS NULL OR grp=%(grp)s) AND (%(number)s::int IS NULL OR number=%(number)s)
+ AND (%(since)s::timestamptz IS NULL OR created_at>=%(since)s)'''
+ with db.connect() as c:
+ counts = {r['grp']: r['n'] for r in c.execute(ENTRIES + ' SELECT grp, count(*) AS n FROM entries GROUP BY grp', params).fetchall()}
+ total = c.execute(ENTRIES + ' SELECT count(*) AS n FROM entries ' + where, params).fetchone()['n']
+ rows = c.execute(ENTRIES + ' SELECT * FROM entries ' + where +
+ ' ORDER BY created_at DESC LIMIT %(size)s OFFSET %(offset)s', params).fetchall()
+ items = []
+ for r in rows:
+ entry = {'kind': r['kind'], 'id': r['id'], 'number': r['number'], 'state': r['state'],
+ 'group': r['grp'], 'total_cents': r['total_cents'], 'created_at': r['created_at'],
+ 'items': [{'mode': i['mode'], 'billed_metres': i.get('billed_metres')} for i in r['items']]}
+ if r['kind'] == 'quote':
+ # A PIX code still open is paid on its own page.
+ pix = c.execute('''SELECT 1 FROM dtf_local.payment_intents WHERE quote_id=%s AND method='pix'
+ AND status='pending' AND COALESCE((response->>'expires_at')::timestamptz > now(), false)''',
+ (r['id'],)).fetchone()
+ entry['pay_with'] = 'pix' if pix else 'page'
+ items.append(entry)
+ return {'items': items, 'total': total, 'page': page, 'size': size,
+ 'counts': {g: counts.get(g, 0) for g in GROUPS}, 'groups': GROUPS, 'states': STATES}
@router.get('/api/customer/orders/{oid}')
def detail(oid: UUID, identity=Depends(owner)):
@@ -103,7 +204,8 @@ def detail(oid: UUID, identity=Depends(owner)):
history = c.execute('''SELECT from_state,to_state,CASE WHEN to_state='cor' THEN reason ELSE '' END AS reason,
created_at FROM dtf_local.movements WHERE order_id=%s AND NOT back ORDER BY id''', (oid,)).fetchall()
return {'id': row['id'], 'number': row['number'], 'state': row['state'], 'version': row['version'],
- 'snapshot': row['snapshot'], 'history': history, 'files': file_rows(c,oid)}
+ 'created_at': row['created_at'], 'snapshot': row['snapshot'], 'history': history,
+ 'files': file_rows(c,oid)}
@router.post('/api/customer/orders/{oid}/corrections')
def correction(oid: UUID, body: ArtworkSubmission, identity=Depends(owner)):
diff --git a/app/core/models.py b/app/core/models.py
index b9d0507..806cca6 100644
--- a/app/core/models.py
+++ b/app/core/models.py
@@ -219,6 +219,35 @@ class Register(StrictModel):
customer: Customer
password: str = Field(min_length=12, max_length=128)
+class ProfileUpdate(StrictModel):
+ """What a customer may change about their account; the CNPJ is not among it."""
+ zap: str
+ address: Destination | None = None
+
+ @field_validator('zap')
+ @classmethod
+ def phone_valid(cls, value):
+ digits = re.sub(r'\D', '', value)
+ if len(digits) not in (10,11) or not digits.isascii():
+ raise ValueError('Invalid phone')
+ return digits
+
+class EmailChange(StrictModel):
+ email: str = Field(max_length=254)
+ password: str = Field(min_length=1, max_length=128)
+
+ @field_validator('email')
+ @classmethod
+ def email_valid(cls, value):
+ value = value.strip().lower()
+ if not re.fullmatch(r'[^\s@]+@[^\s@]+\.[a-zA-Z]{2,}', value):
+ raise ValueError('Invalid email')
+ return value
+
+class PasswordChange(StrictModel):
+ current: str = Field(min_length=1, max_length=128)
+ new: str = Field(min_length=12, max_length=128)
+
class Login(StrictModel):
email: str = Field(min_length=3, max_length=254)
password: str = Field(min_length=1, max_length=128)
diff --git a/deploy/nginx.conf.template b/deploy/nginx.conf.template
index eb33d83..f80b4b8 100644
--- a/deploy/nginx.conf.template
+++ b/deploy/nginx.conf.template
@@ -53,6 +53,11 @@ server {
expires -1;
try_files /index.html =404;
}
+ # The customer's area: one page, which shows the right part for each address.
+ location ~ ^/conta(/(entrar|pedidos|dados))?/?$ {
+ expires -1;
+ try_files /portal.html =404;
+ }
location / {
expires -1;
try_files $uri $uri/ =404;
diff --git a/infra/nginx.conf.template b/infra/nginx.conf.template
index 10fc420..787cff4 100644
--- a/infra/nginx.conf.template
+++ b/infra/nginx.conf.template
@@ -23,6 +23,10 @@ server {
location ~ ^/(arquivo-por-metro|artes-avulsas|uv-arquivo-por-metro|uv-artes-avulsas|carrinho|pagamento|pagamento/pix)/?$ {
try_files /index.html =404;
}
+ # The customer's area: one page, which shows the right part for each address.
+ location ~ ^/conta(/(entrar|pedidos|dados))?/?$ {
+ try_files /portal.html =404;
+ }
location / { try_files $uri $uri/ =404; }
}
server {
diff --git a/tests/browser_test.mjs b/tests/browser_test.mjs
index 06e3f29..2fb8d1a 100644
--- a/tests/browser_test.mjs
+++ b/tests/browser_test.mjs
@@ -192,14 +192,20 @@ try{
await waitFor(()=>site.eval('window.scrollY===0'),'screenshot scroll position');
await site.screenshot('output/local/site.png');
await kanban.screenshot('output/local/kanban.png');
- const portal=await page(siteOrigin+'/portal.html?order='+oid);
+ // A guest sees the order paid in this browser; creating an account keeps it.
+ const portal=await page(siteOrigin+'/conta/pedidos?order='+oid);
await waitFor(async()=> (await portal.text()).includes('Finalizado'),'customer order tracking');
+ await waitFor(()=>portal.eval('document.querySelector("#order-'+oid+' .detalhe")?.hidden===false'),'order from the link opened');
+ await portal.click('#navEntrar');
+ await waitFor(()=>portal.eval('location.pathname==="/conta/entrar" && !document.getElementById("auth").hidden'),'sign-in page');
await portal.fill('#cnpj','11222333000181');await portal.fill('#phone','11999999999');
await portal.fill('#register-email','browser-'+Date.now()+'@example.test');
await portal.fill('#register-password','local-browser-password-123');
await portal.eval('document.getElementById("register").requestSubmit()');
- await waitFor(()=>portal.eval('document.getElementById("auth").hidden'),'customer registration');
- assert.ok((await portal.text()).includes('Finalizado'));
+ await waitFor(()=>portal.eval('document.getElementById("auth").hidden && location.pathname==="/conta"'),'customer registration');
+ await waitFor(async()=> (await portal.text()).includes('Finalizado'),'overview shows the last order');
+ await portal.click('[data-view="dados"]');
+ await waitFor(()=>portal.eval('location.pathname==="/conta/dados" && document.getElementById("dCnpj").value==="11.222.333/0001-81"'),'account details');
await portal.screenshot('output/local/portal.png');
// A logout must clear draft file blobs and metadata, including other open Site tabs.
await portal.eval(`(async()=>{const db=await new Promise((resolve,reject)=>{const r=indexedDB.open('dtf-local-cart',1);r.onsuccess=()=>resolve(r.result);r.onerror=reject;});await new Promise((resolve,reject)=>{const tx=db.transaction('cart','readwrite');tx.objectStore('cart').put({items:[new File(['private'],'private.cdr')],expires:Date.now()+86400000},'security-fixture');tx.oncomplete=resolve;tx.onerror=reject;});db.close();})()`);
diff --git a/tests/workflow_test.py b/tests/workflow_test.py
index c857ac7..a00c064 100644
--- a/tests/workflow_test.py
+++ b/tests/workflow_test.py
@@ -15,7 +15,23 @@ def run():
password='local-test-password-'+uuid4().hex
customer.call('/account/register',{'customer':profile,'password':password})
assert customer.call('/account/me')['customer']['mail']==profile['mail']
- assert customer.call('/customer/orders')['orders'][0]['id']==oid
+ assert [e['id'] for e in customer.call('/customer/orders')['items'] if e['kind']=='order'][0]==oid
+ # The list filters, pages and counts by group.
+ listing=customer.call('/customer/orders?status=prod&page=1&size=1')
+ assert listing['counts']['prod']>=1 and listing['total']==listing['counts']['prod'] and len(listing['items'])==1, listing
+ assert customer.call('/customer/orders?status=fin')['items']==[]
+ assert customer.call('/customer/orders?number='+str(order['number']))['items'][0]['id']==oid
+ # The account's details change; the CNPJ does not, and e-mail and password need the password.
+ address={'recipient':'Workflow Ltda','street':'Rua de Teste','number':'10','complement':'',
+ 'district':'Centro','city':'Franca','state':'SP','postal_code':'14400000'}
+ saved=customer.call('/account/profile',{'zap':'(16) 98888-7777','address':address})['customer']
+ assert saved['zap']=='16988887777' and saved['cnpj']==profile['cnpj'] and saved['address']['city']=='Franca'
+ assert customer.call('/account/me')['customer']['address']['postal_code']=='14400000'
+ customer.call('/account/profile',{'zap':'16988887777','cnpj':'00000000000000'},expected=422)
+ customer.call('/account/email',{'email':'x-'+profile['mail'],'password':'wrong-password'},expected=401)
+ customer.call('/account/password',{'current':'wrong-password','new':'another-password-123'},expected=401)
+ other.call('/account/profile',{'zap':'16988887777'},expected=401)
+ print('PASS: customer order list filters and pages; account details change, CNPJ does not')
# Email/CNPJ do not grant ownership; only current guest session is migrated.
other.call('/customer/orders/'+oid,expected=404)
other.call('/account/login',{'email':profile['mail'],'password':'wrong-password'},expected=401)
@@ -72,7 +88,7 @@ def run():
old_cookie=list(other.jar)[0].value
other.call('/account/logout',{})
cookie.value=old_cookie;revoked.jar.set_cookie(cookie);revoked.call('/customer/orders',expected=401)
- other.call('/session');assert other.call('/customer/orders')['orders']==[]
+ other.call('/session');assert other.call('/customer/orders')['items']==[]
print('PASS: logout revokes server session and signed-out visitors cannot see account orders')
if __name__=='__main__':run()
diff --git a/web/cart.js b/web/cart.js
index e826179..e91172f 100644
--- a/web/cart.js
+++ b/web/cart.js
@@ -39,9 +39,23 @@
pedido=saved.items;
$('atual').style.display='none';pintaEntrega();
notice.textContent='Carrinho recuperado. Remova e adicione novamente um item se precisar alterar sua montagem.';
- }else{
- const account=await window.dtfApi('/account/me');
- if(account.customer && !cliente.mail){cliente={...account.customer};for(const [id,key] of [['fCnpj','cnpj'],['fZap','zap'],['fMail','mail']])$(id).value=cliente[key];pintaEntrega();}
+ }
+ // A signed-in customer's details come from the account, with the saved
+ // delivery address, whether or not a cart was recovered.
+ const account=await window.dtfApi('/account/me');
+ const c=account.customer;
+ if(c && !cliente.mail){
+ cliente={cnpj:c.cnpj,zap:c.zap,mail:c.mail};
+ // The fields' own input handlers format them, as if typed.
+ for(const [id,key] of [['fCnpj','cnpj'],['fZap','zap'],['fMail','mail']]){$(id).value=cliente[key];$(id).dispatchEvent(new Event('input'));}
+ const a=c.address;
+ if(a && !entrega.cep){
+ entrega.cep=a.postal_code;
+ entrega.end={nome:a.recipient,rua:a.street,num:a.number,comp:a.complement||'',bairro:a.district,cidade:a.city,uf:a.state};
+ $('cepIn').value=a.postal_code.replace(/^(\d{5})(\d{3})$/,'$1-$2');
+ for(const [id,key] of [['eNome','nome'],['eRua','rua'],['eNum','num'],['eComp','comp'],['eBairro','bairro'],['eCidade','cidade'],['eUf','uf']])$(id).value=entrega.end[key];
+ }
+ pintaEntrega();
}
}catch(error){notice.textContent='Recuperação de carrinho indisponível; o pedido ainda pode ser feito nesta sessão.';}
finally{restoring=false;}
diff --git a/web/checkout.js b/web/checkout.js
index 10b0c34..ff157c0 100644
--- a/web/checkout.js
+++ b/web/checkout.js
@@ -396,7 +396,7 @@
titulo.insertAdjacentHTML('afterbegin', icone('okCirculo'));
box.append(titulo,
node('p','Pedido #'+quote.order.number+' recebido e enviado para a produção. Você acompanha cada etapa em Meus pedidos.'));
- button('Ver meus pedidos',()=>location.assign('/portal.html?order='+quote.order.id),box);
+ button('Ver meus pedidos',()=>location.assign('/conta/pedidos?order='+quote.order.id),box);
actions.append(box);
}
const cartOk=()=>!!quotedCart && quotedCart===cartSnapshot();
diff --git a/web/index.html b/web/index.html
index 3805586..133f4d7 100644
--- a/web/index.html
+++ b/web/index.html
@@ -651,7 +651,7 @@ h2 em{font-style:normal;color:var(--laranja)}