Add dark-mode business logo, payment order steps, and Arvan DNS diagnostics.

Store logo_dark_media_id on businesses, expose logoDarkUrl on tenant/website APIs, prepend awaiting-payment steps to order workflows, and surface clearer Arvan API network errors.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-08-26 00:21:46 +03:30
co-authored by Cursor
parent e942348b1d
commit 6ce6f959ac
17 changed files with 237 additions and 29 deletions
@@ -0,0 +1,10 @@
-- Optional dark-theme logo for business profile / storefront dashboards
ALTER TABLE businesses
ADD COLUMN IF NOT EXISTS logo_dark_media_id BIGINT;
ALTER TABLE businesses
DROP CONSTRAINT IF EXISTS businesses_logo_dark_media_id_fkey;
ALTER TABLE businesses
ADD CONSTRAINT businesses_logo_dark_media_id_fkey
FOREIGN KEY (logo_dark_media_id) REFERENCES media (id) ON DELETE SET NULL;
+5
View File
@@ -169,6 +169,11 @@
"type": "string",
"nullable": true
},
"logoDarkUrl": {
"type": "string",
"nullable": true,
"description": "Optional logo for dark backgrounds; falls back to logoUrl when omitted."
},
"faviconUrl": {
"type": "string",
"nullable": true
+3
View File
@@ -69,6 +69,7 @@ model Business {
phoneNumbers Json @default("[]") @map("phone_numbers")
socialMedia Json @default("{}") @map("social_media")
logoMediaId BigInt? @map("logo_media_id")
logoDarkMediaId BigInt? @map("logo_dark_media_id")
faviconMediaId BigInt? @map("favicon_media_id")
oldBusinessId BigInt? @map("old_business_id")
addresses Address[]
@@ -79,6 +80,7 @@ model Business {
businessUsers BusinessUser[]
faviconMedia Media? @relation("BusinessFavicon", fields: [faviconMediaId], references: [id], onUpdate: NoAction)
logoMedia Media? @relation("BusinessLogo", fields: [logoMediaId], references: [id], onUpdate: NoAction)
logoDarkMedia Media? @relation("BusinessLogoDark", fields: [logoDarkMediaId], references: [id], onUpdate: NoAction)
carts Cart[]
categories Category[]
contentCategoryAssignments CategoryAssignment[]
@@ -288,6 +290,7 @@ model Media {
brandImages Brand[]
faviconBusinesses Business[] @relation("BusinessFavicon")
logoBusinesses Business[] @relation("BusinessLogo")
logoDarkBusinesses Business[] @relation("BusinessLogoDark")
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction)
uploader User? @relation(fields: [uploadedBy], references: [id], onUpdate: NoAction)
attachments MediaAttachment[]
+26 -2
View File
@@ -226,9 +226,33 @@ export class ArvanDnsService {
'Content-Type': 'application/json',
},
body: body === undefined ? undefined : JSON.stringify(body),
signal: AbortSignal.timeout(20_000),
});
} catch {
throw new ServiceUnavailableException('Could not reach ArvanCloud DNS API');
} catch (err) {
const cause =
err && typeof err === 'object' && 'cause' in err
? (err as { cause?: unknown }).cause
: undefined;
const detail =
(cause &&
typeof cause === 'object' &&
'code' in cause &&
typeof (cause as { code: unknown }).code === 'string' &&
(cause as { code: string }).code) ||
(cause &&
typeof cause === 'object' &&
'message' in cause &&
typeof (cause as { message: unknown }).message === 'string' &&
(cause as { message: string }).message) ||
(err instanceof Error ? err.message : null);
this.logger.warn(
`Arvan DNS request failed ${method} ${path}${detail ? `: ${detail}` : ''}`,
);
throw new ServiceUnavailableException(
detail
? `Could not reach ArvanCloud DNS API (${detail})`
: 'Could not reach ArvanCloud DNS API',
);
}
const text = await response.text().catch(() => '');
+73 -24
View File
@@ -30,6 +30,8 @@ import { parseUserProfile } from './profile.util';
import { SmsService } from './sms.service';
const OTP_TTL_SECONDS = 300;
/** Pending cross-site password change (longer than OTP so resend still works). */
const PENDING_PASSWORD_TTL_SECONDS = 600;
const HANDOFF_TTL_SECONDS = 60;
const HANDOFF_RATE_LIMIT = 10;
const HANDOFF_RATE_WINDOW_SECONDS = 60;
@@ -78,13 +80,14 @@ export class AuthService {
);
}
let passwordMatchesExisting = false;
if (existingUser) {
const passwordValid = await bcrypt.compare(
passwordMatchesExisting = await bcrypt.compare(
dto.password,
existingUser.passwordHash,
);
if (!passwordValid && !dto.acknowledgeExistingAccount) {
if (!passwordMatchesExisting && !dto.acknowledgeExistingAccount) {
const otherNames = existingUser.businessCustomers
.map((item) => item.business.nameFa?.trim() || item.business.name)
.filter(Boolean);
@@ -98,18 +101,49 @@ export class AuthService {
}
const linkedExistingAccount = Boolean(existingUser);
// New password claimed on another site — apply only after OTP proves ownership.
const wantsPasswordUpdate =
linkedExistingAccount && !passwordMatchesExisting;
// Hold new password in Redis until OTP; do not touch DB password or membership yet.
if (existingUser && wantsPasswordUpdate && smsEnabled) {
await this.redis.setPendingPasswordUpdate(
dto.cellNumber,
{
passwordHash,
businessId: business.id.toString(),
},
PENDING_PASSWORD_TTL_SECONDS,
);
const authUser = await this.getAuthUser(existingUser.id);
return {
message:
'Please verify your cell number with OTP. Your Meshkee password will be updated after verification.',
smsEnabled: true,
requiresOtp: true,
passwordPending: true,
passwordUpdated: false,
user: this.serializeUser(authUser),
registeredBusiness: {
id: business.id,
name: business.name,
slug: business.slug,
},
};
}
const userId = await this.prisma.$transaction(async (tx) => {
let userId: bigint;
if (existingUser) {
// Cross-site join: replace Meshkee password with the one entered on this signup.
const updated = await tx.user.update({
where: { id: existingUser.id },
data: { passwordHash },
select: { id: true },
});
userId = updated.id;
if (wantsPasswordUpdate) {
await tx.user.update({
where: { id: existingUser.id },
data: { passwordHash },
});
}
userId = existingUser.id;
} else {
const created = await tx.user.create({
data: {
@@ -157,15 +191,15 @@ export class AuthService {
const tokens = await this.issueTokens(authUser);
return {
message: linkedExistingAccount
? smsEnabled
? 'Joined this website. Your Meshkee password was updated to the one you just entered. Please verify your cell number with OTP.'
: 'Joined this website. Your Meshkee password was updated to the one you just entered.'
: smsEnabled
? 'Registration successful. Please verify your cell number with OTP.'
: 'Registration successful. SMS verification is disabled — account auto-verified.',
message: wantsPasswordUpdate
? 'Joined this website. Your Meshkee password was updated to the one you just entered.'
: linkedExistingAccount
? 'Joined this website with your existing Meshkee account.'
: smsEnabled
? 'Registration successful. Please verify your cell number with OTP.'
: 'Registration successful. SMS verification is disabled — account auto-verified.',
smsEnabled,
passwordUpdated: linkedExistingAccount,
passwordUpdated: wantsPasswordUpdate,
user: this.serializeUser(authUser),
registeredBusiness: {
id: business.id,
@@ -439,10 +473,8 @@ export class AuthService {
async loginWithOtp(cellNumber: string, code: string, domain?: string) {
const user = await this.consumeOtp(cellNumber, code);
if (domain?.trim()) {
await this.ensureCustomerMembershipForDomain(user.id, domain.trim());
}
const pending = await this.redis.consumePendingPasswordUpdate(cellNumber);
const passwordUpdated = Boolean(pending?.passwordHash);
await this.prisma.user.update({
where: { id: user.id },
@@ -451,14 +483,28 @@ export class AuthService {
isActive: true,
cellVerifiedAt: user.cellVerifiedAt ?? new Date(),
lastLoginAt: new Date(),
...(pending?.passwordHash
? { passwordHash: pending.passwordHash }
: {}),
},
});
if (pending?.businessId) {
await this.ensureCustomerMembership(user.id, BigInt(pending.businessId));
}
if (domain?.trim()) {
await this.ensureCustomerMembershipForDomain(user.id, domain.trim());
}
const authUser = await this.getAuthUser(user.id);
const tokens = await this.issueTokens(authUser);
return {
message: 'Login successful',
message: passwordUpdated
? 'Login successful. Your Meshkee password was updated.'
: 'Login successful',
passwordUpdated,
user: this.serializeUser(authUser),
...tokens,
};
@@ -510,17 +556,20 @@ export class AuthService {
domain: string,
) {
const business = await this.tenant.resolveBusinessByDomain(domain);
await this.ensureCustomerMembership(userId, business.id);
}
private async ensureCustomerMembership(userId: bigint, businessId: bigint) {
const existing = await this.prisma.businessCustomer.findUnique({
where: {
businessId_userId: { businessId: business.id, userId },
businessId_userId: { businessId, userId },
},
});
if (!existing) {
await this.prisma.businessCustomer.create({
data: {
businessId: business.id,
businessId,
userId,
},
});
+5 -1
View File
@@ -489,14 +489,18 @@ export class LegacyPurgeService {
id: businessId,
OR: [
{ logoMediaId: { in: uniqueIds } },
{ logoDarkMediaId: { in: uniqueIds } },
{ faviconMediaId: { in: uniqueIds } },
],
},
select: { logoMediaId: true, faviconMediaId: true },
select: { logoMediaId: true, logoDarkMediaId: true, faviconMediaId: true },
});
if (businessMedia?.logoMediaId != null) {
stillLinked.add(businessMedia.logoMediaId.toString());
}
if (businessMedia?.logoDarkMediaId != null) {
stillLinked.add(businessMedia.logoDarkMediaId.toString());
}
if (businessMedia?.faviconMediaId != null) {
stillLinked.add(businessMedia.faviconMediaId.toString());
}
@@ -68,6 +68,7 @@ export class BusinessProfileService {
categoryAssignments: true,
addresses: { orderBy: { createdAt: 'asc' } },
logoMedia: true,
logoDarkMedia: true,
faviconMedia: true,
},
});
@@ -99,6 +100,7 @@ export class BusinessProfileService {
categoryAssignments: true,
addresses: { orderBy: { createdAt: 'asc' } },
logoMedia: true,
logoDarkMedia: true,
faviconMedia: true,
},
});
@@ -140,6 +142,7 @@ export class BusinessProfileService {
categoryAssignments: true,
addresses: true,
logoMedia: true,
logoDarkMedia: true,
faviconMedia: true,
},
});
@@ -156,6 +159,10 @@ export class BusinessProfileService {
await this.assertLogoMedia(businessId, BigInt(dto.logoMediaId));
}
if (dto.logoDarkMediaId !== undefined && dto.logoDarkMediaId !== null) {
await this.assertLogoMedia(businessId, BigInt(dto.logoDarkMediaId));
}
const previousFaviconMediaId = business.faviconMediaId;
const logoChanged =
dto.logoMediaId !== undefined &&
@@ -196,6 +203,13 @@ export class BusinessProfileService {
}
}
if (dto.logoDarkMediaId !== undefined) {
data.logoDarkMedia =
dto.logoDarkMediaId === null
? { disconnect: true }
: { connect: { id: BigInt(dto.logoDarkMediaId) } };
}
if (Object.keys(data).length > 0) {
await tx.business.update({
where: { id: businessId },
@@ -376,6 +390,8 @@ export class BusinessProfileService {
socialMedia: unknown;
logoMediaId: bigint | null;
logoMedia: { publicUrl: string } | null;
logoDarkMediaId: bigint | null;
logoDarkMedia: { publicUrl: string } | null;
faviconMediaId: bigint | null;
faviconMedia: { publicUrl: string } | null;
categoryAssignments: { categoryId: bigint }[];
@@ -390,6 +406,8 @@ export class BusinessProfileService {
socialMedia: normalizeSocialMedia(business.socialMedia),
logoMediaId: business.logoMediaId?.toString() ?? null,
logoUrl: business.logoMedia?.publicUrl ?? null,
logoDarkMediaId: business.logoDarkMediaId?.toString() ?? null,
logoDarkUrl: business.logoDarkMedia?.publicUrl ?? null,
faviconMediaId: business.faviconMediaId?.toString() ?? null,
faviconUrl:
business.faviconMedia?.publicUrl ??
@@ -33,6 +33,8 @@ export type BusinessProfile = {
socialMedia: BusinessSocialMedia;
logoMediaId: string | null;
logoUrl: string | null;
logoDarkMediaId: string | null;
logoDarkUrl: string | null;
faviconMediaId: string | null;
faviconUrl: string | null;
categoryIds: string[];
@@ -110,6 +110,11 @@ export class UpdateBusinessProfileDto {
@IsInt()
logoMediaId?: number | null;
@IsOptional()
@Type(() => Number)
@IsInt()
logoDarkMediaId?: number | null;
@IsOptional()
@IsArray()
@Type(() => Number)
@@ -199,6 +199,18 @@ export type BusinessSettings = {
};
export const DEFAULT_ORDER_PROCESS_STEPS: OrderProcessStep[] = [
{
id: 'awaiting-payment',
label: 'Waiting for payment',
labelFa: 'در انتظار پرداخت',
color: '#64748B',
},
{
id: 'payment-successful',
label: 'Payment successful',
labelFa: 'پرداخت موفق',
color: '#10B981',
},
{
id: 'processing',
label: 'Under processing',
@@ -320,6 +320,19 @@ export function listPublicPaymentGateways(settings: EPaymentSettings) {
};
}
const PAYMENT_PROCESS_STEP_IDS = ['awaiting-payment', 'payment-successful'] as const;
/** Prepend default payment steps when an older store settings JSON omits them. */
function ensurePaymentProcessSteps(steps: OrderProcessStep[]): OrderProcessStep[] {
const missing = DEFAULT_ORDER_PROCESS_STEPS.filter(
(defaults) =>
PAYMENT_PROCESS_STEP_IDS.includes(
defaults.id as (typeof PAYMENT_PROCESS_STEP_IDS)[number],
) && !steps.some((step) => step.id === defaults.id),
);
return missing.length ? [...missing, ...steps] : steps;
}
function readOrderProcessSteps(value: unknown): OrderProcessStep[] {
if (!Array.isArray(value)) {
return DEFAULT_ORDER_PROCESS_STEPS;
@@ -351,7 +364,9 @@ function readOrderProcessSteps(value: unknown): OrderProcessStep[] {
})
.filter((step): step is OrderProcessStep => step !== null);
return steps.length ? steps : DEFAULT_ORDER_PROCESS_STEPS;
return steps.length
? ensurePaymentProcessSteps(steps)
: DEFAULT_ORDER_PROCESS_STEPS;
}
export function normalizeBusinessSettings(raw: unknown): BusinessSettings {
@@ -28,6 +28,8 @@ export const ORDER_STEP_COLOR_HEXES = [
export const DEFAULT_ORDER_STEP_COLOR = '#3B82F6';
const DEFAULT_STEP_COLORS_BY_ID: Record<string, string> = {
'awaiting-payment': '#64748B',
'payment-successful': '#10B981',
processing: '#3B82F6',
'ready-for-shipping': '#F59E0B',
shipped: '#8B5CF6',
+3 -1
View File
@@ -267,10 +267,11 @@ export class MediaService {
id: businessId,
OR: [
{ logoMediaId: { in: uniqueIds } },
{ logoDarkMediaId: { in: uniqueIds } },
{ faviconMediaId: { in: uniqueIds } },
],
},
select: { logoMediaId: true, faviconMediaId: true },
select: { logoMediaId: true, logoDarkMediaId: true, faviconMediaId: true },
}),
]);
@@ -285,6 +286,7 @@ export class MediaService {
for (const row of sliders) addId(row.image_media_id);
for (const row of staticItems) addId(row.image_media_id);
addId(business?.logoMediaId);
addId(business?.logoDarkMediaId);
addId(business?.faviconMediaId);
const deletableIds = uniqueIds.filter((id) => !stillLinked.has(id.toString()));
+48
View File
@@ -22,6 +22,54 @@ export class RedisService {
await this.redis.del(`otp:${cellNumber}`);
}
/**
* Cross-site signup with a new password: hold the hash until OTP proves
* phone ownership, then apply it in login-otp.
*/
async setPendingPasswordUpdate(
cellNumber: string,
payload: { passwordHash: string; businessId: string },
ttlSeconds: number,
): Promise<void> {
await this.redis.set(
`auth:pending-password:${cellNumber}`,
JSON.stringify(payload),
'EX',
ttlSeconds,
);
}
async consumePendingPasswordUpdate(
cellNumber: string,
): Promise<{ passwordHash: string; businessId: string } | null> {
const key = `auth:pending-password:${cellNumber}`;
const result = await this.redis.multi().get(key).del(key).exec();
const raw = result?.[0]?.[1];
if (typeof raw !== 'string' || !raw) {
return null;
}
try {
const parsed = JSON.parse(raw) as {
passwordHash?: unknown;
businessId?: unknown;
};
if (
typeof parsed.passwordHash !== 'string' ||
typeof parsed.businessId !== 'string' ||
!parsed.passwordHash ||
!parsed.businessId
) {
return null;
}
return {
passwordHash: parsed.passwordHash,
businessId: parsed.businessId,
};
} catch {
return null;
}
}
async setHandoffTicket(
ticket: string,
userId: string,
+2
View File
@@ -44,6 +44,7 @@ export class TenantService {
where: { id: business.id },
select: {
logoMedia: { select: { publicUrl: true } },
logoDarkMedia: { select: { publicUrl: true } },
faviconMedia: { select: { publicUrl: true } },
},
});
@@ -61,6 +62,7 @@ export class TenantService {
homeCharts: settings.modules.charts,
specialProductsSource: settings.website.specialProductsSource,
logoUrl: media?.logoMedia?.publicUrl ?? null,
logoDarkUrl: media?.logoDarkMedia?.publicUrl ?? null,
faviconUrl:
media?.faviconMedia?.publicUrl ?? media?.logoMedia?.publicUrl ?? null,
ePayment: listPublicPaymentGateways(settings.store.ePayment),
+5
View File
@@ -169,6 +169,11 @@
"type": "string",
"nullable": true
},
"logoDarkUrl": {
"type": "string",
"nullable": true,
"description": "Optional logo for dark backgrounds; falls back to logoUrl when omitted."
},
"faviconUrl": {
"type": "string",
"nullable": true
@@ -21,6 +21,7 @@ export class WebsiteBusinessInfoService {
where: { id: business.id },
include: {
logoMedia: true,
logoDarkMedia: true,
faviconMedia: true,
addresses: { orderBy: { createdAt: 'asc' } },
},
@@ -37,6 +38,7 @@ export class WebsiteBusinessInfoService {
about: record.about ?? '',
vision: record.vision ?? '',
logoUrl: record.logoMedia?.publicUrl ?? null,
logoDarkUrl: record.logoDarkMedia?.publicUrl ?? null,
faviconUrl:
record.faviconMedia?.publicUrl ?? record.logoMedia?.publicUrl ?? null,
emails: normalizeEmails(record.emails),