fix production issue
This commit is contained in:
@@ -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 {
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|||||||
@@ -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) }
|
||||||
|
|||||||
@@ -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 } })
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 } })
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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,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
|
||||||
@@ -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 company’s own payment flow, so customer ownership and cash collection stay with the business.'],
|
['Direct revenue path', 'Bookings move into the company’s 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 à l’entreprise, pour garder la relation client et l’encaissement.'],
|
['Revenus en direct', 'Les réservations basculent vers le paiement propre à l’entreprise, pour garder la relation client et l’encaissement.'],
|
||||||
].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: 'يجب أن تشرح الصفحة الرئيسية قيمة المنصة خلال ثوانٍ.',
|
||||||
|
|||||||
Reference in New Issue
Block a user