diff --git a/database/migrations/068_business_logo_dark.sql b/database/migrations/068_business_logo_dark.sql new file mode 100644 index 0000000..a8056c2 --- /dev/null +++ b/database/migrations/068_business_logo_dark.sql @@ -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; diff --git a/docs/website-api/openapi.json b/docs/website-api/openapi.json index 828bf16..c074032 100644 --- a/docs/website-api/openapi.json +++ b/docs/website-api/openapi.json @@ -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 diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 39ece41..8dc52cc 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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[] diff --git a/src/arvan-dns/arvan-dns.service.ts b/src/arvan-dns/arvan-dns.service.ts index ec4ab50..9230383 100644 --- a/src/arvan-dns/arvan-dns.service.ts +++ b/src/arvan-dns/arvan-dns.service.ts @@ -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(() => ''); diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index 5977a23..cadeb32 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -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, }, }); diff --git a/src/business-admin/legacy-purge.service.ts b/src/business-admin/legacy-purge.service.ts index a642e9f..65f2b1f 100644 --- a/src/business-admin/legacy-purge.service.ts +++ b/src/business-admin/legacy-purge.service.ts @@ -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()); } diff --git a/src/business-profile/business-profile.service.ts b/src/business-profile/business-profile.service.ts index 11a92fa..a619e98 100644 --- a/src/business-profile/business-profile.service.ts +++ b/src/business-profile/business-profile.service.ts @@ -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 ?? diff --git a/src/business-profile/business-profile.types.ts b/src/business-profile/business-profile.types.ts index dcf8687..506a3cc 100644 --- a/src/business-profile/business-profile.types.ts +++ b/src/business-profile/business-profile.types.ts @@ -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[]; diff --git a/src/business-profile/dto/update-business-profile.dto.ts b/src/business-profile/dto/update-business-profile.dto.ts index add749f..ab8730e 100644 --- a/src/business-profile/dto/update-business-profile.dto.ts +++ b/src/business-profile/dto/update-business-profile.dto.ts @@ -110,6 +110,11 @@ export class UpdateBusinessProfileDto { @IsInt() logoMediaId?: number | null; + @IsOptional() + @Type(() => Number) + @IsInt() + logoDarkMediaId?: number | null; + @IsOptional() @IsArray() @Type(() => Number) diff --git a/src/business-settings/business-settings.types.ts b/src/business-settings/business-settings.types.ts index 7ebe132..2e0b0f3 100644 --- a/src/business-settings/business-settings.types.ts +++ b/src/business-settings/business-settings.types.ts @@ -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', diff --git a/src/business-settings/business-settings.util.ts b/src/business-settings/business-settings.util.ts index 07d4bf1..3e4d3da 100644 --- a/src/business-settings/business-settings.util.ts +++ b/src/business-settings/business-settings.util.ts @@ -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 { diff --git a/src/business-settings/order-step-colors.ts b/src/business-settings/order-step-colors.ts index 0cbafd4..420d231 100644 --- a/src/business-settings/order-step-colors.ts +++ b/src/business-settings/order-step-colors.ts @@ -28,6 +28,8 @@ export const ORDER_STEP_COLOR_HEXES = [ export const DEFAULT_ORDER_STEP_COLOR = '#3B82F6'; const DEFAULT_STEP_COLORS_BY_ID: Record = { + 'awaiting-payment': '#64748B', + 'payment-successful': '#10B981', processing: '#3B82F6', 'ready-for-shipping': '#F59E0B', shipped: '#8B5CF6', diff --git a/src/media/media.service.ts b/src/media/media.service.ts index b2e4cda..a3366f3 100644 --- a/src/media/media.service.ts +++ b/src/media/media.service.ts @@ -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())); diff --git a/src/redis/redis.service.ts b/src/redis/redis.service.ts index 8119f31..f9fd975 100644 --- a/src/redis/redis.service.ts +++ b/src/redis/redis.service.ts @@ -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 { + 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, diff --git a/src/tenant/tenant.service.ts b/src/tenant/tenant.service.ts index 06425d9..0ec9ca9 100644 --- a/src/tenant/tenant.service.ts +++ b/src/tenant/tenant.service.ts @@ -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), diff --git a/src/website-docs/static/openapi.json b/src/website-docs/static/openapi.json index 828bf16..c074032 100644 --- a/src/website-docs/static/openapi.json +++ b/src/website-docs/static/openapi.json @@ -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 diff --git a/src/website/website-business-info.service.ts b/src/website/website-business-info.service.ts index 759de4d..30ac0e1 100644 --- a/src/website/website-business-info.service.ts +++ b/src/website/website-business-info.service.ts @@ -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),