parity(apps+api): complete_profile in both apps [ref: henza_server Major/CustomerController::complete]

Ports "تکمیل پروفایل" end to end (D22).

API
- POST customer/me/complete-profile — three required document ids, the
  customer taken from the token, verification step set to the reference's
  LIVE value (approved outright; its `= 1` line is commented out at :83 in
  favour of `= 2` at :85). Both SMS messages byte-identical and non-blocking
  per D20.
- POST customer/me/documents — the reference let the app upload through the
  same file/create the admin used, inside its auth:api-customer group
  (routes/api.php:95); the target's copy is admin-guarded, so the page could
  not have uploaded at all. Reuses AdminUploadsService unchanged, so both
  paths share one size limit, MIME whitelist and naming scheme (UPL-01).
- PROFILE_ADMIN_MOBILES replaces the reference's four hardcoded personal
  numbers; blank means nobody is notified and the upload still succeeds.

Three reference behaviours deliberately NOT ported, all in ISSUES.md:
- SEC-14: `$customer->update($request->all())` mass-assigned the whole request
  body, letting the app write its own credit limit, making charge, gold debt,
  active flag or mobile. Replaced with a three-field whitelist.
- SEC-15: four real personal mobiles hardcoded in the source.
- APP-03: refresh()'s unterminated setInterval page reload.

Apps (both, template+script byte-identical, per-app styling only)
- CompleteProfileView + SingleImageUploader
- route /complete_profile, redirecting home once the documents are in, as the
  reference's guard did (router/index.ts:266)
- the entry point on the profile screen, shown only while they are missing
  (profile.vue:96-102)

Also (D23): JWT_CUSTOMER_SECRET now warns loudly at boot when unset. It falls
back to JWT_SECRET, which signs app and admin tokens with the same key and
leaves the `typ` check as the only barrier — and an expert's app token carries
their AdminUser id in `sub`. Warn rather than refuse to boot (D19, lowest
risk); DELIVERY.md lists it as the first pre-release step (REL-03).

Verified: build 5/5 · jest 45/45 · complete-profile 38/38 (new) ·
expert login 36/36 · apps smoke 43/43 · admin smoke 43/43 ·
B2B scan 34 ok/0 broken · typecheck clean on all four apps.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
s.esmaeili
2026-09-23 01:42:07 +03:30
co-authored by Claude Opus 5
parent f71fd5bfe1
commit 5ad1083aee
115 changed files with 1746 additions and 99 deletions
+47
View File
@@ -281,4 +281,51 @@ export const api = {
'/expert/child-orders',
{ method: 'POST', body: JSON.stringify({ page, search: search ?? null }) },
),
// ── complete_profile ─────────────────────────────────────────────────
// Ports POST /major/complete (henza_server Major/CustomerController::complete)
// and the PWA's document uploads, which went through the same file/create the
// admin used but behind the customer guard (routes/api.php:95).
/**
* Uploads one document and resolves its stored id, which is what
* completeProfile sends. multipart/form-data, so it bypasses `request()` —
* that helper forces a JSON content-type, and setting it here would break
* the multipart boundary.
*/
uploadDocument: async (file: File, purpose: string): Promise<{ id: string; url: string }> => {
const body = new FormData();
body.append('file', file);
body.append('purpose', purpose);
const token = readToken();
const res = await fetch(`${BASE}/customer/me/documents`, {
method: 'POST',
// No Content-Type: the browser sets it with the boundary.
headers: token ? { Authorization: `Bearer ${token}` } : {},
body,
});
if (res.status === 401) {
onUnauthorized?.();
throw new UnauthorizedError('/customer/me/documents');
}
if (!res.ok) {
throw new Error(`آپلود فایل ناموفق بود (${res.status})`);
}
return res.json();
},
/**
* The three ids keep the reference's snake_case keys so the payload matches
* what henza_pwa sent (completeProfile.vue:136-140).
*/
completeProfile: (payload: {
business_license_image_id: string;
shop_image_id: string;
ncode_image_id: string;
}) =>
request<{ ok: boolean }>('/customer/me/complete-profile', {
method: 'POST',
body: JSON.stringify(payload),
}),
};
@@ -0,0 +1,166 @@
<!--
One document slot for "تکمیل پروفایل".
Ports henza_pwa's SingleImageUploader (/@/components/SingleImageUploader),
which the reference used three times on completeProfile.vue:45-56. Its
contract is the reference's: pick one file, upload it immediately, and emit
the stored id through `done` — the form only holds ids, never the files.
This is the user-app styling; user-app-v2 has its own. The SCRIPT is
identical in both.
-->
<template>
<div class="siu" :class="{ 'is-done': Boolean(fileId), 'is-error': Boolean(error) }">
<input
ref="input"
type="file"
accept="image/jpeg,image/png,image/webp,application/pdf"
class="siu__input"
@change="onPick"
/>
<button type="button" class="siu__box" :disabled="busy" @click="input?.click()">
<img v-if="preview" :src="preview" class="siu__preview" alt="" />
<span v-else class="siu__icon">
<Icon :icon="busy ? 'vuesax-linear:refresh' : 'vuesax-linear:gallery-add'" size="26" />
</span>
<span class="siu__text">
{{ busy ? 'در حال بارگذاری…' : fileId ? 'تغییر تصویر' : uploadText }}
</span>
<span v-if="fileId && !busy" class="siu__badge">
<Icon icon="vuesax-linear:tick-circle" size="16" />
</span>
</button>
<p v-if="error" class="siu__error">{{ error }}</p>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import Icon from '../icon/Icon.vue'
import { api } from '../../api/client'
const props = defineProps<{
uploadText: string
/** Sent through as the stored file's `purpose`, so documents stay labelled. */
purpose: string
}>()
const emit = defineEmits<{ (e: 'done', id: string): void }>()
const input = ref<HTMLInputElement | null>(null)
const busy = ref(false)
const error = ref<string | null>(null)
const fileId = ref<string | null>(null)
const preview = ref<string | null>(null)
/** 8 MB — the same ceiling the API enforces, checked here for a fast message. */
const MAX_BYTES = 8 * 1024 * 1024
async function onPick(event: Event) {
const target = event.target as HTMLInputElement
const file = target.files?.[0]
// Reset immediately so re-picking the same file fires `change` again.
target.value = ''
if (!file) return
error.value = null
if (file.size > MAX_BYTES) {
error.value = 'حجم فایل بیش از حد مجاز است (۸ مگابایت)'
return
}
busy.value = true
try {
const stored = await api.uploadDocument(file, props.purpose)
fileId.value = stored.id
preview.value = file.type.startsWith('image/') ? URL.createObjectURL(file) : null
emit('done', stored.id)
} catch {
error.value = 'بارگذاری انجام نشد. دوباره تلاش کنید.'
fileId.value = null
preview.value = null
} finally {
busy.value = false
}
}
</script>
<style scoped lang="less">
.siu__input {
display: none;
}
.siu__box {
position: relative;
width: 100%;
min-height: 132px;
padding: 20px 16px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
border: 1px dashed @hairline;
border-radius: 18px;
background: @canvasSoft;
font-family: inherit;
cursor: pointer;
overflow: hidden;
transition: border-color 0.2s ease;
}
.siu__box:disabled {
cursor: progress;
}
.is-done .siu__box {
border-style: solid;
border-color: #16a34a;
}
.is-error .siu__box {
border-color: #dc2626;
}
.siu__preview {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
opacity: 0.32;
}
.siu__icon,
.siu__text {
position: relative;
z-index: 1;
}
.siu__text {
font-size: 13px;
font-weight: 600;
color: @inkMuted;
}
.siu__badge {
position: absolute;
top: 10px;
left: 10px;
z-index: 1;
display: flex;
color: #16a34a;
}
.siu__error {
margin: 7px 2px 0;
font-size: 12px;
color: #dc2626;
}
</style>
+28 -1
View File
@@ -2,6 +2,7 @@ import { createRouter, createWebHistory } from 'vue-router';
import { useSessionStore } from '../stores/session';
import { useNavStore } from '../stores/navStore';
import { useTransitionStore } from '../stores/transitionStore';
import { api } from '../api/client';
// Legacy lazy-loaded every route component (`component: () => import(...)`) so
// the initial bundle only carries the entry route. Matched here.
@@ -52,6 +53,15 @@ export const router = createRouter({
component: () => import('../views/expert/CustomerChoiceView.vue'),
meta: { title: 'انتخاب مشتری', layout: 'FullPage', requiresAuth: true, requiresExpert: true },
},
// Reference path and name kept (henza_pwa router/index.ts:208-215). The
// guard below mirrors the reference's: once the documents are in, the page
// redirects home (henza_pwa router/index.ts:266).
{
path: '/complete_profile',
name: 'completeProfile',
component: () => import('../views/user/CompleteProfileView.vue'),
meta: { title: 'تکمیل پروفایل', layout: 'FullPage', requiresAuth: true, requiresIncompleteProfile: true },
},
{ path: '/notifications', name: 'notifications', component: () => import('../views/user/NotificationsView.vue') },
{ path: '/search', name: 'search', component: () => import('../views/search/SearchView.vue') },
{ path: '/collection/:id?', name: 'collection', component: () => import('../views/search/CollectionView.vue') },
@@ -65,7 +75,7 @@ export const router = createRouter({
// user away from /login, and blocks unauthenticated access to routes that
// require a session (legacy blocked ordersList/transactionsList/profileData/
// favoritesList/completeProfile the same way).
router.beforeEach((to) => {
router.beforeEach(async (to) => {
const session = useSessionStore();
const nav = useNavStore();
@@ -83,6 +93,23 @@ router.beforeEach((to) => {
return { name: 'userPage::hasSubRoute' };
}
// Ported from henza_pwa router/index.ts:266 — once the documents are in,
// /complete_profile is not reachable any more and redirects home. The
// reference read its cached store; this asks the server, so a profile
// completed on another device is respected too. A failed lookup lets the
// navigation through: the page itself is harmless, and the server is the
// one that decides whether the write is allowed.
if (to.meta.requiresIncompleteProfile && session.isLoggedIn) {
try {
const me = await api.getMe();
if (me.verificationStep === 'VERIFIED' || me.verificationStep === 'STEP_TWO_SUBMITTED') {
return { name: 'home' };
}
} catch {
// fall through
}
}
// legacy router/index.ts:268-270 — the product page slides down into view.
if (to.name === 'singleProduct') nav.setPgaTransition('slideDown');
else nav.setPgaTransition(undefined);
@@ -0,0 +1,189 @@
<!--
"تکمیل پروفایل" — the customer uploads their three documents.
Ported from henza_pwa/src/views/user/src/completeProfile.vue (route
/complete_profile, name completeProfile), restyled into this app's design
language. The LOGIC is the reference's and is identical in user-app-v2; only
the styling differs.
Kept from the reference:
- the header title "تکمیل پروفایل" (completeProfile.vue:12) and the back
arrow returning to the dashboard (:11)
- exactly three uploads, in the reference's order and with its texts:
جواز کسب, سردر مغازه, کارت ملی (:45-56)
- the submit button "بارگذاری اطلاعات" (:81)
- the client-side all-three check BEFORE posting, with the reference's own
message "لطفا تمامی عکس های خواسته شده را آپلود کنید" (:191-198)
- POST with the three snake_case ids (:193)
- the success dialog with its text and its "متوجه شدم" button (:94-111),
after which the reference navigated home (:175-181)
Deliberately NOT ported: the reference's `window.setInterval(... reload)` in
refresh() (:178-180), which reloaded the whole page every second FOREVER —
an unterminated timer, not a refresh. The store is re-read once instead.
The page is reachable only while the profile is incomplete; see the router
guard, which mirrors the reference's (henza_pwa router/index.ts:266).
-->
<template>
<div class="cpf">
<PageBar title="تکمیل پروفایل" back-to="userPage::hasSubRoute" />
<Spinner :spinning="submitting" tip="در حال ثبت اطلاعات">
<container class="cpf__body">
<p class="cpf__lead">
برای تکمیل پروفایل، سه مدرک زیر را بارگذاری کنید.
</p>
<form class="cpf__form" @submit.prevent="submit">
<SingleImageUploader
upload-text="بارگذاری عکس جواز کسب"
purpose="business-license"
@done="(id: string) => (form.business_license_image_id = id)"
/>
<SingleImageUploader
upload-text="بارگذاری عکس سردر مغازه"
purpose="shop-image"
@done="(id: string) => (form.shop_image_id = id)"
/>
<SingleImageUploader
upload-text="بارگذاری عکس کارت ملی"
purpose="national-card"
@done="(id: string) => (form.ncode_image_id = id)"
/>
<p v-if="error" class="cpf__error">{{ error }}</p>
<button type="submit" class="cpf__submit" :disabled="submitting">
بارگذاری اطلاعات
</button>
</form>
</container>
</Spinner>
<DialogVantOneButton
v-model:show="done"
:time-out="-1"
:close-on-click-overlay="false"
>
<template #title>
<div class="cpf__dialogText">
تکمیل اطلاعات شما با موفقیت انجام شد.
</div>
</template>
<template #button>
<div class="cpf__dialogBtn" @click="finish">متوجه شدم</div>
</template>
</DialogVantOneButton>
</div>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { showNotify } from 'vant'
import PageBar from '../../components/ui/PageBar.vue'
import Spinner from '../../components/Spinner.vue'
import DialogVantOneButton from '../../components/DialogVantOneButton.vue'
import SingleImageUploader from '../../components/upload/SingleImageUploader.vue'
import { api } from '../../api/client'
const router = useRouter()
const form = reactive({
business_license_image_id: null as string | null,
shop_image_id: null as string | null,
ncode_image_id: null as string | null,
})
const submitting = ref(false)
const done = ref(false)
const error = ref<string | null>(null)
async function submit() {
error.value = null
// The reference checked all three client-side before posting
// (completeProfile.vue:191-198); the server requires them too.
if (!form.business_license_image_id || !form.shop_image_id || !form.ncode_image_id) {
showNotify('لطفا تمامی عکس های خواسته شده را آپلود کنید')
return
}
submitting.value = true
try {
await api.completeProfile({
business_license_image_id: form.business_license_image_id,
shop_image_id: form.shop_image_id,
ncode_image_id: form.ncode_image_id,
})
done.value = true
} catch {
error.value = 'ثبت اطلاعات انجام نشد. دوباره تلاش کنید.'
} finally {
submitting.value = false
}
}
/** The reference sent the user home after the dialog (:175-177). */
function finish() {
done.value = false
router.push({ name: 'home' })
}
</script>
<style scoped lang="less">
.cpf__body {
padding-top: 90px;
padding-bottom: 40px;
}
.cpf__lead {
margin: 4px 0 18px;
font-size: 13px;
color: @inkMuted;
}
.cpf__form {
display: flex;
flex-direction: column;
gap: 14px;
}
.cpf__error {
margin: 2px;
font-size: 12.5px;
color: #dc2626;
}
.cpf__submit {
margin-top: 6px;
width: 100%;
padding: 14px;
border: none;
border-radius: 14px;
background: @ink;
color: #fff;
font-family: inherit;
font-size: 15px;
font-weight: 700;
cursor: pointer;
}
.cpf__submit:disabled {
opacity: 0.6;
cursor: progress;
}
.cpf__dialogText {
padding: 20px;
line-height: 1.9;
text-align: justify;
font-size: 14px;
}
.cpf__dialogBtn {
text-align: center;
cursor: pointer;
}
</style>
@@ -60,12 +60,27 @@
<Icon icon="vuesax-linear:user" size="26" />
<p>اطلاعات حساب در دسترس نیست.</p>
</section>
<!--
"تکمیل پروفایل" — the reference showed this button here, on the profile,
and only while sts_verify_step_two was neither 1 nor 2
(henza_pwa .../components/profile.vue:96-102). The same condition holds
here against the target VerificationStep enum.
-->
<button
v-if="canCompleteProfile"
type="button"
class="prof__complete"
@click="router.push({ name: 'completeProfile' })"
>
تکمیل پروفایل
</button>
</container>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import Icon from '../../components/icon/Icon.vue'
import PageBar from '../../components/ui/PageBar.vue'
import SectionHead from '../../components/home/SectionHead.vue'
@@ -85,6 +100,18 @@ const loading = ref(false)
const isVerified = computed(() => customer.value?.verificationStep === 'VERIFIED')
/**
* The reference gated the button on sts_verify_step_two being neither 1 nor 2,
* i.e. show it only while the documents have not been submitted. STEP_TWO_
* SUBMITTED and VERIFIED are those two states here.
*/
const canCompleteProfile = computed(() => {
const step = customer.value?.verificationStep
return Boolean(step) && step !== 'VERIFIED' && step !== 'STEP_TWO_SUBMITTED'
})
const router = useRouter()
onMounted(async () => {
if (!session.isLoggedIn) return
loading.value = true
@@ -213,4 +240,18 @@ onMounted(async () => {
margin: 0;
font-size: 12.5px;
}
.prof__complete {
margin-top: 20px;
width: 100%;
padding: 14px;
border: none;
border-radius: 14px;
background: @ink;
color: #fff;
font-family: inherit;
font-size: 15px;
font-weight: 700;
cursor: pointer;
}
</style>