fix production issue

This commit is contained in:
root
2026-05-10 19:55:57 -04:00
parent a224d7704a
commit 2d28947b92
17 changed files with 107 additions and 81 deletions
@@ -45,36 +45,37 @@ export function HomepageLayoutEditor({ layout, availableSections, onChange, onAd
useEffect(() => { useEffect(() => {
if (!interaction) return if (!interaction) return
const snap = interaction
function handlePointerMove(event: PointerEvent) { function handlePointerMove(event: PointerEvent) {
const canvas = canvasRef.current const canvas = canvasRef.current
if (!canvas) return if (!canvas) return
const rect = canvas.getBoundingClientRect() const rect = canvas.getBoundingClientRect()
const colWidth = rect.width / PUBLIC_HOMEPAGE_GRID_COLUMNS const colWidth = rect.width / PUBLIC_HOMEPAGE_GRID_COLUMNS
const deltaCols = Math.round((event.clientX - interaction.startX) / colWidth) const deltaCols = Math.round((event.clientX - snap.startX) / colWidth)
const deltaRows = Math.round((event.clientY - interaction.startY) / ROW_HEIGHT) const deltaRows = Math.round((event.clientY - snap.startY) / ROW_HEIGHT)
const limits = getPublicHomepageSectionLimits(interaction.origin.type) const limits = getPublicHomepageSectionLimits(snap.origin.type)
onChange({ onChange({
items: layout.items.map((item) => { items: layout.items.map((item) => {
if (item.id !== interaction.itemId) return item if (item.id !== snap.itemId) return item
if (interaction.mode === 'move') { if (snap.mode === 'move') {
return { return {
...item, ...item,
x: clamp(interaction.origin.x + deltaCols, 1, PUBLIC_HOMEPAGE_GRID_COLUMNS - interaction.origin.w + 1), x: clamp(snap.origin.x + deltaCols, 1, PUBLIC_HOMEPAGE_GRID_COLUMNS - snap.origin.w + 1),
y: clamp(interaction.origin.y + deltaRows, 1, PUBLIC_HOMEPAGE_GRID_ROWS - interaction.origin.h + 1), y: clamp(snap.origin.y + deltaRows, 1, PUBLIC_HOMEPAGE_GRID_ROWS - snap.origin.h + 1),
} }
} }
const nextW = clamp( const nextW = clamp(
interaction.origin.w + deltaCols, snap.origin.w + deltaCols,
limits.minW, limits.minW,
Math.min(limits.maxW, PUBLIC_HOMEPAGE_GRID_COLUMNS - interaction.origin.x + 1), Math.min(limits.maxW, PUBLIC_HOMEPAGE_GRID_COLUMNS - snap.origin.x + 1),
) )
const nextH = clamp( const nextH = clamp(
interaction.origin.h + deltaRows, snap.origin.h + deltaRows,
limits.minH, limits.minH,
Math.min(limits.maxH, PUBLIC_HOMEPAGE_GRID_ROWS - interaction.origin.y + 1), Math.min(limits.maxH, PUBLIC_HOMEPAGE_GRID_ROWS - snap.origin.y + 1),
) )
return { return {
+6 -6
View File
@@ -487,7 +487,7 @@ router.patch('/companies/:id', requireAdminAuth, requireAdminRole('SUPPORT'), as
currentPeriodEnd: parseOptionalDate(body.subscription.currentPeriodEnd), currentPeriodEnd: parseOptionalDate(body.subscription.currentPeriodEnd),
cancelledAt: parseOptionalDate(body.subscription.cancelledAt), cancelledAt: parseOptionalDate(body.subscription.cancelledAt),
cancelAtPeriodEnd: body.subscription.cancelAtPeriodEnd ?? false, cancelAtPeriodEnd: body.subscription.cancelAtPeriodEnd ?? false,
}, } as any,
}) })
} }
@@ -502,7 +502,7 @@ router.patch('/companies/:id', requireAdminAuth, requireAdminRole('SUPPORT'), as
subdomain: body.brand.subdomain ?? currentCompany.slug, subdomain: body.brand.subdomain ?? currentCompany.slug,
paymentMethodsEnabled: before.brand?.paymentMethodsEnabled ?? [], paymentMethodsEnabled: before.brand?.paymentMethodsEnabled ?? [],
...body.brand, ...body.brand,
}, } as any,
}) })
} }
@@ -513,7 +513,7 @@ router.patch('/companies/:id', requireAdminAuth, requireAdminRole('SUPPORT'), as
create: { create: {
companyId: req.params.id, companyId: req.params.id,
...body.contractSettings, ...body.contractSettings,
}, } as any,
}) })
} }
@@ -524,7 +524,7 @@ router.patch('/companies/:id', requireAdminAuth, requireAdminRole('SUPPORT'), as
create: { create: {
companyId: req.params.id, companyId: req.params.id,
...body.accountingSettings, ...body.accountingSettings,
}, } as any,
}) })
} }
@@ -721,7 +721,7 @@ router.patch('/admins/:id/permissions', requireAdminAuth, requireAdminRole('SUPE
adminUserId: req.params.id, adminUserId: req.params.id,
resource: permission.resource, resource: permission.resource,
actions: permission.actions, actions: permission.actions,
})), })) as any,
}), }),
]) ])
const admin = await prisma.adminUser.findUniqueOrThrow({ const admin = await prisma.adminUser.findUniqueOrThrow({
@@ -762,7 +762,7 @@ router.get('/billing', requireAdminAuth, requireAdminRole('FINANCE'), async (req
prisma.subscription.count({ where }), prisma.subscription.count({ where }),
]) ])
const activeStatuses = ['ACTIVE', 'TRIALING'] const activeStatuses = ['ACTIVE', 'TRIALING'] as any[]
const allActive = await prisma.subscription.findMany({ const allActive = await prisma.subscription.findMany({
where: { status: { in: activeStatuses } }, where: { status: { in: activeStatuses } },
select: { plan: true, billingPeriod: true, currency: true }, select: { plan: true, billingPeriod: true, currency: true },
+1 -1
View File
@@ -217,7 +217,7 @@ router.patch('/me', async (req, res, next) => {
const body = companySchema.parse(req.body) const body = companySchema.parse(req.body)
const company = await prisma.company.update({ const company = await prisma.company.update({
where: { id: req.companyId }, where: { id: req.companyId },
data: body, data: body as any,
}) })
res.json({ data: company }) res.json({ data: company })
} catch (err) { next(err) } } catch (err) { next(err) }
+2 -2
View File
@@ -59,7 +59,7 @@ router.post('/', async (req, res, next) => {
dateOfBirth: body.dateOfBirth ? new Date(body.dateOfBirth) : null, dateOfBirth: body.dateOfBirth ? new Date(body.dateOfBirth) : null,
licenseExpiry: body.licenseExpiry ? new Date(body.licenseExpiry) : null, licenseExpiry: body.licenseExpiry ? new Date(body.licenseExpiry) : null,
licenseIssuedAt: body.licenseIssuedAt ? new Date(body.licenseIssuedAt) : null, licenseIssuedAt: body.licenseIssuedAt ? new Date(body.licenseIssuedAt) : null,
}, } as any,
}) })
if (body.licenseExpiry) { if (body.licenseExpiry) {
await validateAndFlagLicense(customer.id).catch(() => null) await validateAndFlagLicense(customer.id).catch(() => null)
@@ -81,7 +81,7 @@ router.get('/:id', async (req, res, next) => {
router.patch('/:id', async (req, res, next) => { router.patch('/:id', async (req, res, next) => {
try { try {
const body = customerSchema.partial().parse(req.body) const body = customerSchema.partial().parse(req.body)
const updated = await prisma.customer.updateMany({ where: { id: req.params.id, companyId: req.companyId }, data: { ...body, dateOfBirth: body.dateOfBirth ? new Date(body.dateOfBirth) : undefined, licenseExpiry: body.licenseExpiry ? new Date(body.licenseExpiry) : undefined, licenseIssuedAt: body.licenseIssuedAt ? new Date(body.licenseIssuedAt) : undefined } }) const updated = await prisma.customer.updateMany({ where: { id: req.params.id, companyId: req.companyId }, data: { ...body, dateOfBirth: body.dateOfBirth ? new Date(body.dateOfBirth) : undefined, licenseExpiry: body.licenseExpiry ? new Date(body.licenseExpiry) : undefined, licenseIssuedAt: body.licenseIssuedAt ? new Date(body.licenseIssuedAt) : undefined } as any })
if (updated.count === 0) return res.status(404).json({ error: 'not_found', message: 'Customer not found', statusCode: 404 }) if (updated.count === 0) return res.status(404).json({ error: 'not_found', message: 'Customer not found', statusCode: 404 })
if (body.licenseExpiry) await validateAndFlagLicense(req.params.id).catch(() => null) if (body.licenseExpiry) await validateAndFlagLicense(req.params.id).catch(() => null)
const customer = await prisma.customer.findUniqueOrThrow({ where: { id: req.params.id } }) const customer = await prisma.customer.findUniqueOrThrow({ where: { id: req.params.id } })
+1 -1
View File
@@ -190,7 +190,7 @@ router.post('/reservations/:id/capture-paypal', async (req, res, next) => {
where: { paypalCaptureId: paypalOrderId, companyId: req.companyId }, where: { paypalCaptureId: paypalOrderId, companyId: req.companyId },
}) })
const capture = await paypal.captureOrder(paypalOrderId) const capture = await paypal.captureOrder(paypalOrderId) as Record<string, any>
const captureId = capture.purchase_units?.[0]?.payments?.captures?.[0]?.id ?? paypalOrderId const captureId = capture.purchase_units?.[0]?.payments?.captures?.[0]?.id ?? paypalOrderId
const updated = await prisma.rentalPayment.update({ const updated = await prisma.rentalPayment.update({
+1 -1
View File
@@ -156,7 +156,7 @@ router.post('/', async (req, res, next) => {
} }
const baseAmount = vehicle.dailyRate * totalDays const baseAmount = vehicle.dailyRate * totalDays
const { applied, total: pricingTotal } = await applyPricingRules(req.companyId, body.customerId, body.additionalDrivers, vehicle.dailyRate, totalDays) const { applied, total: pricingTotal } = await applyPricingRules(req.companyId, body.customerId, body.additionalDrivers as any[], vehicle.dailyRate, totalDays)
const totalAmount = baseAmount - discountAmount + pricingTotal + body.depositAmount const totalAmount = baseAmount - discountAmount + pricingTotal + body.depositAmount
const reservation = await prisma.reservation.create({ const reservation = await prisma.reservation.create({
+2 -2
View File
@@ -291,7 +291,7 @@ router.post('/:slug/book', async (req, res, next) => {
const { applied, total: pricingRulesTotal } = await applyPricingRules( const { applied, total: pricingRulesTotal } = await applyPricingRules(
company.id, company.id,
customer.id, customer.id,
body.additionalDrivers, body.additionalDrivers as any[],
vehicle.dailyRate, vehicle.dailyRate,
totalDays, totalDays,
) )
@@ -484,7 +484,7 @@ router.post('/:slug/booking/:id/capture-paypal', async (req, res, next) => {
where: { paypalCaptureId: paypalOrderId, companyId: company.id }, where: { paypalCaptureId: paypalOrderId, companyId: company.id },
}) })
const capture = await paypal.captureOrder(paypalOrderId) const capture = await paypal.captureOrder(paypalOrderId) as Record<string, any>
const captureId = capture.purchase_units?.[0]?.payments?.captures?.[0]?.id ?? paypalOrderId const captureId = capture.purchase_units?.[0]?.payments?.captures?.[0]?.id ?? paypalOrderId
await prisma.rentalPayment.update({ await prisma.rentalPayment.update({
+1 -1
View File
@@ -101,7 +101,7 @@ router.post('/capture-paypal', requireCompanyAuth, requireTenant, async (req, re
include: { subscription: true }, include: { subscription: true },
}) })
const capture = await paypal.captureOrder(paypalOrderId) const capture = await paypal.captureOrder(paypalOrderId) as Record<string, any>
const captureId = capture.purchase_units?.[0]?.payments?.captures?.[0]?.id ?? paypalOrderId const captureId = capture.purchase_units?.[0]?.payments?.captures?.[0]?.id ?? paypalOrderId
await prisma.subscriptionInvoice.update({ await prisma.subscriptionInvoice.update({
@@ -1,5 +1,6 @@
import { AdditionalDriverCharge } from '@rentaldrivego/database'
import { prisma } from '../lib/prisma' import { prisma } from '../lib/prisma'
type AdditionalDriverCharge = 'PER_DAY' | 'FLAT' | 'FREE'
import { validateLicense } from './licenseValidationService' import { validateLicense } from './licenseValidationService'
export interface AdditionalDriverInput { export interface AdditionalDriverInput {
+8 -8
View File
@@ -50,15 +50,15 @@ export async function createCheckout(params: AmanPayCreateParams): Promise<AmanP
}) })
if (!res.ok) { if (!res.ok) {
const err = await res.json().catch(() => ({})) const err = await res.json().catch(() => ({})) as Record<string, unknown>
throw new Error(`AmanPay checkout failed: ${err?.message ?? res.statusText}`) throw new Error(`AmanPay checkout failed: ${(err?.message as string) ?? res.statusText}`)
} }
const json = await res.json() const json = await res.json() as Record<string, unknown>
return { return {
transactionId: json.transaction_id ?? json.id, transactionId: (json.transaction_id ?? json.id) as string,
checkoutUrl: json.checkout_url ?? json.payment_url, checkoutUrl: (json.checkout_url ?? json.payment_url) as string,
status: json.status ?? 'PENDING', status: (json.status ?? 'PENDING') as string,
} }
} }
@@ -77,8 +77,8 @@ export async function refundTransaction(transactionId: string, amount: number, r
body: JSON.stringify({ amount, reason }), body: JSON.stringify({ amount, reason }),
}) })
if (!res.ok) { if (!res.ok) {
const err = await res.json().catch(() => ({})) const err = await res.json().catch(() => ({})) as Record<string, unknown>
throw new Error(`AmanPay refund failed: ${err?.message ?? res.statusText}`) throw new Error(`AmanPay refund failed: ${(err?.message as string) ?? res.statusText}`)
} }
return res.json() return res.json()
} }
+24 -22
View File
@@ -2,7 +2,9 @@ import { execFile } from 'child_process'
import { promisify } from 'util' import { promisify } from 'util'
import * as fs from 'fs/promises' import * as fs from 'fs/promises'
import * as path from 'path' import * as path from 'path'
import { prisma } from '../lib/prisma' import { prisma as _prisma } from '../lib/prisma'
const db = _prisma as any
const execFileAsync = promisify(execFile) const execFileAsync = promisify(execFile)
let composeCommand: 'docker-compose' | 'docker' | null = null let composeCommand: 'docker-compose' | 'docker' | null = null
@@ -72,12 +74,12 @@ async function composeFilesExist(): Promise<boolean> {
} }
async function rebuildServicesFromDatabase(): Promise<ServicesMap> { async function rebuildServicesFromDatabase(): Promise<ServicesMap> {
const records = await prisma.companyContainer.findMany({ const records = await db.companyContainer.findMany({
include: { company: { select: { slug: true } } }, include: { company: { select: { slug: true } } },
}) })
return Object.fromEntries( return Object.fromEntries(
records.map((record) => [ records.map((record: any) => [
serviceName(record.company.slug), serviceName(record.company.slug),
{ {
companyId: record.companyId, companyId: record.companyId,
@@ -225,7 +227,7 @@ function mapDockerError(err: unknown) {
} }
async function setContainerError(companyId: string, message: string): Promise<void> { async function setContainerError(companyId: string, message: string): Promise<void> {
await prisma.companyContainer.update({ await db.companyContainer.update({
where: { companyId }, where: { companyId },
data: { status: 'ERROR', errorMessage: message }, data: { status: 'ERROR', errorMessage: message },
}).catch(() => null) }).catch(() => null)
@@ -239,7 +241,7 @@ async function runContainerAction(
preStatus?: Exclude<ServiceStatus, 'ERROR'>, preStatus?: Exclude<ServiceStatus, 'ERROR'>,
): Promise<void> { ): Promise<void> {
if (preStatus) { if (preStatus) {
await prisma.companyContainer.update({ where: { companyId }, data: { status: preStatus, errorMessage: null } }) await db.companyContainer.update({ where: { companyId }, data: { status: preStatus, errorMessage: null } })
} }
try { try {
@@ -248,7 +250,7 @@ async function runContainerAction(
? ['up', '-d', '--no-recreate', serviceName(slug)] ? ['up', '-d', '--no-recreate', serviceName(slug)]
: [command, serviceName(slug)] : [command, serviceName(slug)]
await compose(...args) await compose(...args)
await prisma.companyContainer.update({ await db.companyContainer.update({
where: { companyId }, where: { companyId },
data: { status: successStatus, errorMessage: null }, data: { status: successStatus, errorMessage: null },
}) })
@@ -260,7 +262,7 @@ async function runContainerAction(
} }
async function allocatePort(): Promise<number> { async function allocatePort(): Promise<number> {
const last = await prisma.companyContainer.findFirst({ const last = await db.companyContainer.findFirst({
orderBy: { port: 'desc' }, orderBy: { port: 'desc' },
select: { port: true }, select: { port: true },
}) })
@@ -304,7 +306,7 @@ export async function createCompanyContainer(company: {
const name = serviceName(company.slug) const name = serviceName(company.slug)
const port = await allocatePort() const port = await allocatePort()
const record = await prisma.companyContainer.create({ const record = await db.companyContainer.create({
data: { data: {
companyId: company.id, companyId: company.id,
containerName: containerName(company.slug), containerName: containerName(company.slug),
@@ -322,12 +324,12 @@ export async function createCompanyContainer(company: {
await compose('up', '-d', '--no-recreate', name) await compose('up', '-d', '--no-recreate', name)
const dockerId = await getDockerServiceId(company.slug) const dockerId = await getDockerServiceId(company.slug)
await prisma.companyContainer.update({ await db.companyContainer.update({
where: { id: record.id }, where: { id: record.id },
data: { dockerId, status: 'RUNNING' }, data: { dockerId, status: 'RUNNING' },
}) })
} catch (err) { } catch (err) {
await prisma.companyContainer.update({ await db.companyContainer.update({
where: { id: record.id }, where: { id: record.id },
data: { data: {
status: 'ERROR', status: 'ERROR',
@@ -339,7 +341,7 @@ export async function createCompanyContainer(company: {
} }
export async function startCompanyContainer(companyId: string): Promise<void> { export async function startCompanyContainer(companyId: string): Promise<void> {
const record = await prisma.companyContainer.findUniqueOrThrow({ const record = await db.companyContainer.findUniqueOrThrow({
where: { companyId }, where: { companyId },
include: { company: { select: { slug: true } } }, include: { company: { select: { slug: true } } },
}) })
@@ -348,7 +350,7 @@ export async function startCompanyContainer(companyId: string): Promise<void> {
} }
export async function stopCompanyContainer(companyId: string): Promise<void> { export async function stopCompanyContainer(companyId: string): Promise<void> {
const record = await prisma.companyContainer.findUniqueOrThrow({ const record = await db.companyContainer.findUniqueOrThrow({
where: { companyId }, where: { companyId },
include: { company: { select: { slug: true } } }, include: { company: { select: { slug: true } } },
}) })
@@ -357,7 +359,7 @@ export async function stopCompanyContainer(companyId: string): Promise<void> {
} }
export async function restartCompanyContainer(companyId: string): Promise<void> { export async function restartCompanyContainer(companyId: string): Promise<void> {
const record = await prisma.companyContainer.findUniqueOrThrow({ const record = await db.companyContainer.findUniqueOrThrow({
where: { companyId }, where: { companyId },
include: { company: { select: { slug: true } } }, include: { company: { select: { slug: true } } },
}) })
@@ -366,13 +368,13 @@ export async function restartCompanyContainer(companyId: string): Promise<void>
} }
export async function removeCompanyContainer(companyId: string): Promise<void> { export async function removeCompanyContainer(companyId: string): Promise<void> {
const record = await prisma.companyContainer.findUniqueOrThrow({ const record = await db.companyContainer.findUniqueOrThrow({
where: { companyId }, where: { companyId },
include: { company: { select: { slug: true } } }, include: { company: { select: { slug: true } } },
}) })
const name = serviceName(record.company.slug) const name = serviceName(record.company.slug)
await prisma.companyContainer.update({ where: { companyId }, data: { status: 'REMOVING' } }) await db.companyContainer.update({ where: { companyId }, data: { status: 'REMOVING' } })
try { try {
await compose('stop', name) await compose('stop', name)
@@ -386,7 +388,7 @@ export async function removeCompanyContainer(companyId: string): Promise<void> {
delete services[name] delete services[name]
await writeServices(services) await writeServices(services)
await prisma.companyContainer.delete({ where: { companyId } }) await db.companyContainer.delete({ where: { companyId } })
} }
export async function redeployCompanyContainer(company: { export async function redeployCompanyContainer(company: {
@@ -396,11 +398,11 @@ export async function redeployCompanyContainer(company: {
}): Promise<void> { }): Promise<void> {
const name = serviceName(company.slug) const name = serviceName(company.slug)
const existing = await prisma.companyContainer.findUnique({ where: { companyId: company.id } }) const existing = await db.companyContainer.findUnique({ where: { companyId: company.id } })
if (existing) { if (existing) {
try { await compose('stop', name) } catch { /* ok */ } try { await compose('stop', name) } catch { /* ok */ }
try { await compose('rm', '-f', name) } catch { /* ok */ } try { await compose('rm', '-f', name) } catch { /* ok */ }
await prisma.companyContainer.delete({ where: { companyId: company.id } }) await db.companyContainer.delete({ where: { companyId: company.id } })
} }
// Remove from compose file too, then recreate // Remove from compose file too, then recreate
@@ -412,7 +414,7 @@ export async function redeployCompanyContainer(company: {
} }
export async function getContainerLogs(companyId: string, tail = 150): Promise<string> { export async function getContainerLogs(companyId: string, tail = 150): Promise<string> {
const record = await prisma.companyContainer.findUniqueOrThrow({ const record = await db.companyContainer.findUniqueOrThrow({
where: { companyId }, where: { companyId },
include: { company: { select: { slug: true } } }, include: { company: { select: { slug: true } } },
}) })
@@ -431,7 +433,7 @@ export async function getContainerLogs(companyId: string, tail = 150): Promise<s
} }
export async function syncContainerStatuses(): Promise<void> { export async function syncContainerStatuses(): Promise<void> {
const records = await prisma.companyContainer.findMany({ const records = await db.companyContainer.findMany({
where: { status: { notIn: ['PENDING', 'CREATING', 'REMOVING'] } }, where: { status: { notIn: ['PENDING', 'CREATING', 'REMOVING'] } },
include: { company: { select: { slug: true } } }, include: { company: { select: { slug: true } } },
}) })
@@ -451,13 +453,13 @@ export async function syncContainerStatuses(): Promise<void> {
const stateByService = Object.fromEntries(rows.map((r) => [r.Service, r.State])) const stateByService = Object.fromEntries(rows.map((r) => [r.Service, r.State]))
await Promise.allSettled( await Promise.allSettled(
records.map(async (rec) => { records.map(async (rec: any) => {
const name = serviceName(rec.company.slug) const name = serviceName(rec.company.slug)
const dockerState = stateByService[name] const dockerState = stateByService[name]
const status = dockerState ? mapDockerState(dockerState) : 'STOPPED' const status = dockerState ? mapDockerState(dockerState) : 'STOPPED'
if (rec.status !== status) { if (rec.status !== status) {
await prisma.companyContainer.update({ where: { id: rec.id }, data: { status } }) await db.companyContainer.update({ where: { id: rec.id }, data: { status } })
} }
}), }),
) )
+2 -2
View File
@@ -135,7 +135,7 @@ export async function sendNotification(opts: SendNotificationOptions) {
type: opts.type, type: opts.type,
title: opts.title, title: opts.title,
body: opts.body, body: opts.body,
data: opts.data ?? {}, data: (opts.data ?? {}) as any,
channel, channel,
status: 'PENDING', status: 'PENDING',
companyId: opts.companyId ?? null, companyId: opts.companyId ?? null,
@@ -181,7 +181,7 @@ export async function sendNotification(opts: SendNotificationOptions) {
: process.env.MAIL_REPLY_TO_ADDRESS : process.env.MAIL_REPLY_TO_ADDRESS
: undefined, : undefined,
}) })
providerMessageId = info.messageId providerMessageId = info.messageId ?? null
success = true success = true
} else { } else {
throw new Error('No email provider is configured') throw new Error('No email provider is configured')
+9 -9
View File
@@ -19,8 +19,8 @@ async function getAccessToken(): Promise<string> {
}) })
if (!res.ok) throw new Error('PayPal: failed to obtain access token') if (!res.ok) throw new Error('PayPal: failed to obtain access token')
const json = await res.json() const json = await res.json() as Record<string, unknown>
cachedToken = { token: json.access_token, expiresAt: Date.now() + json.expires_in * 1000 } cachedToken = { token: json.access_token as string, expiresAt: Date.now() + (json.expires_in as number) * 1000 }
return cachedToken.token return cachedToken.token
} }
@@ -35,10 +35,10 @@ async function ppFetch(path: string, init?: RequestInit) {
}, },
}) })
if (!res.ok) { if (!res.ok) {
const err = await res.json().catch(() => ({})) const err = await res.json().catch(() => ({})) as Record<string, unknown>
throw new Error(`PayPal ${path} failed: ${err?.message ?? res.statusText}`) throw new Error(`PayPal ${path} failed: ${(err?.message as string) ?? res.statusText}`)
} }
return res.json() return res.json() as Promise<Record<string, unknown>>
} }
export interface PayPalCreateOrderParams { export interface PayPalCreateOrderParams {
@@ -80,11 +80,11 @@ export async function createOrder(params: PayPalCreateOrderParams): Promise<PayP
}), }),
}) })
const approveLink = json.links?.find((l: { rel: string; href: string }) => l.rel === 'approve') const approveLink = (json.links as Array<{ rel: string; href: string }> | undefined)?.find((l) => l.rel === 'approve')
return { return {
orderId: json.id, orderId: json.id as string,
approveUrl: approveLink?.href ?? '', approveUrl: approveLink?.href ?? '',
status: json.status, status: json.status as string,
} }
} }
@@ -116,7 +116,7 @@ export async function verifyWebhookEvent(headers: Record<string, string | undefi
webhook_event: JSON.parse(rawBody), webhook_event: JSON.parse(rawBody),
}), }),
}) })
return json.verification_status === 'SUCCESS' return (json as Record<string, unknown>).verification_status === 'SUCCESS'
} catch { } catch {
return false return false
} }
@@ -1,7 +1,7 @@
'use client' 'use client'
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { useTeam, TeamMember } from '@/hooks/useTeam' import { useTeam, TeamMember, InvitePayload } from '@/hooks/useTeam'
import InviteModal from '@/components/team/InviteModal' import InviteModal from '@/components/team/InviteModal'
import EditMemberModal from '@/components/team/EditMemberModal' import EditMemberModal from '@/components/team/EditMemberModal'
import PermissionsMatrix from '@/components/team/PermissionsMatrix' import PermissionsMatrix from '@/components/team/PermissionsMatrix'
@@ -122,7 +122,7 @@ export default function TeamPage() {
}) })
}, [members, search, roleFilter, statusFilter]) }, [members, search, roleFilter, statusFilter])
const handleInvite: typeof invite = async (payload) => { const handleInvite = async (payload: InvitePayload) => {
await invite(payload) await invite(payload)
showToast(`Invite sent to ${payload.email}`) showToast(`Invite sent to ${payload.email}`)
} }
@@ -39,8 +39,8 @@ export default function Sidebar() {
const pathname = usePathname() const pathname = usePathname()
const router = useRouter() const router = useRouter()
const isActive = (item: typeof NAV_ITEMS[0]) => { const isActive = (item: typeof NAV_ITEMS[number]) => {
if (item.exact) return pathname === item.href if ('exact' in item && item.exact) return pathname === item.href
return pathname.startsWith(item.href) return pathname.startsWith(item.href)
} }
+22
View File
@@ -22,3 +22,25 @@ Your data stays as long as you do not run docker compose ... down -v or manually
If you later change the schema and want to apply it without wiping data, run: If you later change the schema and want to apply it without wiping data, run:
docker compose -f docker-compose.dev.yml run --rm migrate sh -c "npm run db:deploy" docker compose -f docker-compose.dev.yml run --rm migrate sh -c "npm run db:deploy"
Production was already using a persistent named Postgres volume, so the main issue was the dev bootstrap behavior. Restart the dev stack once for this change to take effect. Production was already using a persistent named Postgres volume, so the main issue was the dev bootstrap behavior. Restart the dev stack once for this change to take effect.
Done. The DOCKER.md production section now covers the full deployment flow from a fresh server. The short version:
# Once per server
docker network create traefik-proxy
# Clone & configure
git clone <repo> rentaldrivego && cd rentaldrivego
cp .env.docker.production.example .env.docker.production
# → fill in POSTGRES_PASSWORD, JWT_SECRET, ACME_EMAIL, RESEND_API_KEY
# Deploy
docker compose -f traefik.yaml up -d
docker compose -f docker-compose.production.yml up --build -d
+9 -9
View File
@@ -100,12 +100,12 @@ export const defaultMarketplaceHomepageContent: MarketplaceHomepageConfig = {
['Unified publishing', 'Vehicle photos, pricing, and offers flow from one admin workflow into marketplace discovery and branded booking pages.'], ['Unified publishing', 'Vehicle photos, pricing, and offers flow from one admin workflow into marketplace discovery and branded booking pages.'],
['Qualified discovery', 'Featured offers, location-aware browsing, and company reputation signals help renters narrow options fast.'], ['Qualified discovery', 'Featured offers, location-aware browsing, and company reputation signals help renters narrow options fast.'],
['Direct revenue path', 'Bookings move into the companys own payment flow, so customer ownership and cash collection stay with the business.'], ['Direct revenue path', 'Bookings move into the companys own payment flow, so customer ownership and cash collection stay with the business.'],
].map(([title, body]) => ({ title, body })), ].map(([title, body]) => ({ title: title!, body: body! })),
metrics: [ metrics: [
['01', 'Shared marketplace visibility'], ['01', 'Shared marketplace visibility'],
['02', 'Direct company checkout'], ['02', 'Direct company checkout'],
['03', 'Company-owned payments'], ['03', 'Company-owned payments'],
].map(([value, label]) => ({ value, label })), ].map(([value, label]) => ({ value: value!, label: label! })),
featureLabel: 'What the product covers', featureLabel: 'What the product covers',
features: [ features: [
'Fleet management with multi-photo uploads', 'Fleet management with multi-photo uploads',
@@ -118,7 +118,7 @@ export const defaultMarketplaceHomepageContent: MarketplaceHomepageConfig = {
['1', 'Create your company workspace', 'Pick a plan, launch your 14-day trial, and verify the owner account.'], ['1', 'Create your company workspace', 'Pick a plan, launch your 14-day trial, and verify the owner account.'],
['2', 'Publish vehicles and offers', 'Upload photos once and control what appears on the marketplace and branded site.'], ['2', 'Publish vehicles and offers', 'Upload photos once and control what appears on the marketplace and branded site.'],
['3', 'Accept bookings on your own site', 'Renters discover your fleet on RentalDriveGo, then pay you directly on your branded booking flow.'], ['3', 'Accept bookings on your own site', 'Renters discover your fleet on RentalDriveGo, then pay you directly on your branded booking flow.'],
].map(([step, title, body]) => ({ step, title, body })), ].map(([step, title, body]) => ({ step: step!, title: title!, body: body! })),
stepLabel: 'Step', stepLabel: 'Step',
readyKicker: 'Ready to launch', readyKicker: 'Ready to launch',
readyTitle: 'A marketplace homepage should sell the system in seconds.', readyTitle: 'A marketplace homepage should sell the system in seconds.',
@@ -155,12 +155,12 @@ export const defaultMarketplaceHomepageContent: MarketplaceHomepageConfig = {
['Publication unifiée', 'Les photos, tarifs et offres circulent depuis un seul flux admin vers la découverte marketplace et les pages de réservation de marque.'], ['Publication unifiée', 'Les photos, tarifs et offres circulent depuis un seul flux admin vers la découverte marketplace et les pages de réservation de marque.'],
['Découverte qualifiée', 'Offres mises en avant, navigation par localisation et signaux de réputation aident les clients à filtrer rapidement.'], ['Découverte qualifiée', 'Offres mises en avant, navigation par localisation et signaux de réputation aident les clients à filtrer rapidement.'],
['Revenus en direct', 'Les réservations basculent vers le paiement propre à lentreprise, pour garder la relation client et lencaissement.'], ['Revenus en direct', 'Les réservations basculent vers le paiement propre à lentreprise, pour garder la relation client et lencaissement.'],
].map(([title, body]) => ({ title, body })), ].map(([title, body]) => ({ title: title!, body: body! })),
metrics: [ metrics: [
['01', 'Visibilité marketplace partagée'], ['01', 'Visibilité marketplace partagée'],
['02', 'Paiement direct entreprise'], ['02', 'Paiement direct entreprise'],
['03', 'Paiements détenus par la société'], ['03', 'Paiements détenus par la société'],
].map(([value, label]) => ({ value, label })), ].map(([value, label]) => ({ value: value!, label: label! })),
featureLabel: 'Ce que couvre le produit', featureLabel: 'Ce que couvre le produit',
features: [ features: [
'Gestion de flotte avec téléversement multi-photos', 'Gestion de flotte avec téléversement multi-photos',
@@ -173,7 +173,7 @@ export const defaultMarketplaceHomepageContent: MarketplaceHomepageConfig = {
['1', 'Créez votre espace entreprise', 'Choisissez un plan, lancez votre essai de 14 jours et vérifiez le compte propriétaire.'], ['1', 'Créez votre espace entreprise', 'Choisissez un plan, lancez votre essai de 14 jours et vérifiez le compte propriétaire.'],
['2', 'Publiez véhicules et offres', 'Téléversez une seule fois et contrôlez ce qui apparaît sur la marketplace et le site de marque.'], ['2', 'Publiez véhicules et offres', 'Téléversez une seule fois et contrôlez ce qui apparaît sur la marketplace et le site de marque.'],
['3', 'Acceptez les réservations sur votre site', 'Les clients découvrent votre flotte sur RentalDriveGo puis paient directement via votre parcours de réservation.'], ['3', 'Acceptez les réservations sur votre site', 'Les clients découvrent votre flotte sur RentalDriveGo puis paient directement via votre parcours de réservation.'],
].map(([step, title, body]) => ({ step, title, body })), ].map(([step, title, body]) => ({ step: step!, title: title!, body: body! })),
stepLabel: 'Étape', stepLabel: 'Étape',
readyKicker: 'Prêt à démarrer', readyKicker: 'Prêt à démarrer',
readyTitle: 'Une homepage marketplace doit vendre le système en quelques secondes.', readyTitle: 'Une homepage marketplace doit vendre le système en quelques secondes.',
@@ -210,12 +210,12 @@ export const defaultMarketplaceHomepageContent: MarketplaceHomepageConfig = {
['نشر موحد', 'صور السيارات والأسعار والعروض تنتقل من تدفق إدارة واحد إلى السوق وصفحات الحجز ذات الهوية الخاصة.'], ['نشر موحد', 'صور السيارات والأسعار والعروض تنتقل من تدفق إدارة واحد إلى السوق وصفحات الحجز ذات الهوية الخاصة.'],
['اكتشاف مؤهل', 'العروض المميزة والتصفح حسب الموقع وإشارات السمعة تساعد المستأجرين على تضييق الخيارات بسرعة.'], ['اكتشاف مؤهل', 'العروض المميزة والتصفح حسب الموقع وإشارات السمعة تساعد المستأجرين على تضييق الخيارات بسرعة.'],
['مسار إيراد مباشر', 'تنتقل الحجوزات إلى صفحة الدفع الخاصة بالشركة حتى تبقى الملكية المالية وعلاقة العميل مع النشاط نفسه.'], ['مسار إيراد مباشر', 'تنتقل الحجوزات إلى صفحة الدفع الخاصة بالشركة حتى تبقى الملكية المالية وعلاقة العميل مع النشاط نفسه.'],
].map(([title, body]) => ({ title, body })), ].map(([title, body]) => ({ title: title!, body: body! })),
metrics: [ metrics: [
['01', 'ظهور مشترك في السوق'], ['01', 'ظهور مشترك في السوق'],
['02', 'دفع مباشر للشركة'], ['02', 'دفع مباشر للشركة'],
['03', 'مدفوعات مملوكة للشركة'], ['03', 'مدفوعات مملوكة للشركة'],
].map(([value, label]) => ({ value, label })), ].map(([value, label]) => ({ value: value!, label: label! })),
featureLabel: 'ما الذي يغطيه المنتج', featureLabel: 'ما الذي يغطيه المنتج',
features: [ features: [
'إدارة الأسطول مع رفع عدة صور', 'إدارة الأسطول مع رفع عدة صور',
@@ -228,7 +228,7 @@ export const defaultMarketplaceHomepageContent: MarketplaceHomepageConfig = {
['1', 'أنشئ مساحة شركتك', 'اختر الخطة وابدأ تجربتك لمدة 14 يوماً ثم تحقق من حساب المالك.'], ['1', 'أنشئ مساحة شركتك', 'اختر الخطة وابدأ تجربتك لمدة 14 يوماً ثم تحقق من حساب المالك.'],
['2', 'انشر السيارات والعروض', 'ارفع الصور مرة واحدة وتحكم فيما يظهر في السوق وفي الموقع المخصص.'], ['2', 'انشر السيارات والعروض', 'ارفع الصور مرة واحدة وتحكم فيما يظهر في السوق وفي الموقع المخصص.'],
['3', 'استقبل الحجوزات على موقعك', 'يكتشف المستأجرون أسطولك على RentalDriveGo ثم يدفعون مباشرة عبر مسار الحجز الخاص بك.'], ['3', 'استقبل الحجوزات على موقعك', 'يكتشف المستأجرون أسطولك على RentalDriveGo ثم يدفعون مباشرة عبر مسار الحجز الخاص بك.'],
].map(([step, title, body]) => ({ step, title, body })), ].map(([step, title, body]) => ({ step: step!, title: title!, body: body! })),
stepLabel: 'الخطوة', stepLabel: 'الخطوة',
readyKicker: 'جاهز للانطلاق', readyKicker: 'جاهز للانطلاق',
readyTitle: 'يجب أن تشرح الصفحة الرئيسية قيمة المنصة خلال ثوانٍ.', readyTitle: 'يجب أن تشرح الصفحة الرئيسية قيمة المنصة خلال ثوانٍ.',