archetecture security fix

This commit is contained in:
root
2026-06-11 03:22:12 -04:00
parent 6def9993da
commit 9483750161
3126 changed files with 177194 additions and 37211 deletions
+1
View File
@@ -0,0 +1 @@
export * from './src/client'
+1
View File
@@ -0,0 +1 @@
module.exports = require('./src/client')
+36
View File
@@ -0,0 +1,36 @@
{
"name": "@rentaldrivego/database",
"version": "1.0.0",
"private": true,
"scripts": {
"db:generate": "prisma generate",
"db:push": "prisma db push",
"db:migrate": "prisma migrate dev",
"db:migrate:deploy": "prisma migrate deploy",
"db:seed": "ts-node --compiler-options '{\"module\":\"CommonJS\"}' prisma/seed.ts",
"db:studio": "prisma studio"
},
"dependencies": {
"@prisma/client": "^5.13.0"
},
"devDependencies": {
"prisma": "^5.13.0",
"ts-node": "^10.9.2",
"typescript": "^5.4.0",
"@types/node": "^20.12.0",
"bcryptjs": "^2.4.3",
"@types/bcryptjs": "^2.4.6"
},
"main": "./src/index.js",
"types": "./src/index.d.ts",
"exports": {
".": {
"types": "./src/index.d.ts",
"default": "./src/index.js"
},
"./client": {
"types": "./src/client.d.ts",
"default": "./src/client.js"
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,2 @@
-- Empty placeholder migration (no schema changes)
SELECT 1;
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "employees" ADD COLUMN IF NOT EXISTS "passwordHash" TEXT;
@@ -0,0 +1,7 @@
-- AlterTable employees
ALTER TABLE "employees" ADD COLUMN IF NOT EXISTS "passwordResetToken" TEXT UNIQUE;
ALTER TABLE "employees" ADD COLUMN IF NOT EXISTS "passwordResetExpiresAt" TIMESTAMPTZ;
-- AlterTable admin_users
ALTER TABLE "admin_users" ADD COLUMN IF NOT EXISTS "passwordResetToken" TEXT UNIQUE;
ALTER TABLE "admin_users" ADD COLUMN IF NOT EXISTS "passwordResetExpiresAt" TIMESTAMPTZ;
@@ -0,0 +1,25 @@
-- CreateEnum
CREATE TYPE "CalendarBlockType" AS ENUM ('MANUAL', 'MAINTENANCE');
-- CreateTable
CREATE TABLE "vehicle_calendar_blocks" (
"id" TEXT NOT NULL,
"vehicleId" TEXT NOT NULL,
"startDate" DATE NOT NULL,
"endDate" DATE NOT NULL,
"reason" TEXT,
"type" "CalendarBlockType" NOT NULL DEFAULT 'MANUAL',
"createdAt" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMPTZ NOT NULL,
CONSTRAINT "vehicle_calendar_blocks_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "vehicle_calendar_blocks_vehicleId_idx" ON "vehicle_calendar_blocks"("vehicleId");
-- CreateIndex
CREATE INDEX "vehicle_calendar_blocks_vehicleId_startDate_endDate_idx" ON "vehicle_calendar_blocks"("vehicleId", "startDate", "endDate");
-- AddForeignKey
ALTER TABLE "vehicle_calendar_blocks" ADD CONSTRAINT "vehicle_calendar_blocks_vehicleId_fkey" FOREIGN KEY ("vehicleId") REFERENCES "vehicles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,9 @@
-- Add reviewToken to reservations for secure one-time review links
ALTER TABLE "reservations" ADD COLUMN "reviewToken" TEXT;
CREATE UNIQUE INDEX "reservations_reviewToken_key" ON "reservations"("reviewToken");
-- Make renterId optional on reviews (reviews can come from non-marketplace customers via token link)
ALTER TABLE "reviews" ALTER COLUMN "renterId" DROP NOT NULL;
ALTER TABLE "reviews" DROP CONSTRAINT IF EXISTS "reviews_renterId_fkey";
ALTER TABLE "reviews" ADD CONSTRAINT "reviews_renterId_fkey"
FOREIGN KEY ("renterId") REFERENCES "renters"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "reservations" ADD COLUMN "vehicleCategory" "VehicleCategory";
@@ -0,0 +1 @@
ALTER TABLE "employees" ADD COLUMN "preferredLanguage" TEXT NOT NULL DEFAULT 'en';
@@ -0,0 +1,2 @@
ALTER TABLE "customers"
ADD COLUMN "licenseImageUrl" TEXT;
@@ -0,0 +1,4 @@
ALTER TABLE "vehicles"
ADD COLUMN "pickupLocations" TEXT[],
ADD COLUMN "allowDifferentDropoff" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "dropoffLocations" TEXT[];
@@ -0,0 +1,10 @@
UPDATE "vehicles"
SET
"pickupLocations" = COALESCE("pickupLocations", ARRAY[]::TEXT[]),
"dropoffLocations" = COALESCE("dropoffLocations", ARRAY[]::TEXT[]);
ALTER TABLE "vehicles"
ALTER COLUMN "pickupLocations" SET DEFAULT ARRAY[]::TEXT[],
ALTER COLUMN "pickupLocations" SET NOT NULL,
ALTER COLUMN "dropoffLocations" SET DEFAULT ARRAY[]::TEXT[],
ALTER COLUMN "dropoffLocations" SET NOT NULL;
@@ -0,0 +1,21 @@
CREATE TABLE "pricing_configs" (
"id" TEXT NOT NULL,
"plan" TEXT NOT NULL,
"billingPeriod" TEXT NOT NULL,
"amount" INTEGER NOT NULL,
"updatedAt" TIMESTAMP(3) NOT NULL,
"updatedBy" TEXT,
CONSTRAINT "pricing_configs_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "pricing_configs_plan_billingPeriod_key" ON "pricing_configs"("plan", "billingPeriod");
-- Seed default prices (amounts in centimes MAD)
INSERT INTO "pricing_configs" ("id", "plan", "billingPeriod", "amount", "updatedAt") VALUES
('prc_starter_monthly', 'STARTER', 'MONTHLY', 2990, NOW()),
('prc_starter_annual', 'STARTER', 'ANNUAL', 29900, NOW()),
('prc_growth_monthly', 'GROWTH', 'MONTHLY', 3990, NOW()),
('prc_growth_annual', 'GROWTH', 'ANNUAL', 39900, NOW()),
('prc_pro_monthly', 'PRO', 'MONTHLY', 99900, NOW()),
('prc_pro_annual', 'PRO', 'ANNUAL', 999000, NOW());
@@ -0,0 +1,66 @@
-- Add new SubscriptionStatus enum values
ALTER TYPE "SubscriptionStatus" ADD VALUE IF NOT EXISTS 'PAYMENT_PENDING';
ALTER TYPE "SubscriptionStatus" ADD VALUE IF NOT EXISTS 'SUSPENDED';
ALTER TYPE "SubscriptionStatus" ADD VALUE IF NOT EXISTS 'EXPIRED';
ALTER TYPE "SubscriptionStatus" ADD VALUE IF NOT EXISTS 'PAUSED';
-- Add new InvoiceStatus enum value
ALTER TYPE "InvoiceStatus" ADD VALUE IF NOT EXISTS 'VOIDED';
-- Extend subscriptions table (camelCase to match Prisma default)
ALTER TABLE "subscriptions"
ADD COLUMN IF NOT EXISTS "trialUsed" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS "paymentPendingSince" TIMESTAMP(3),
ADD COLUMN IF NOT EXISTS "paymentDueAt" TIMESTAMP(3),
ADD COLUMN IF NOT EXISTS "pastDueSince" TIMESTAMP(3),
ADD COLUMN IF NOT EXISTS "suspendedAt" TIMESTAMP(3),
ADD COLUMN IF NOT EXISTS "endedAt" TIMESTAMP(3),
ADD COLUMN IF NOT EXISTS "retryCount" INTEGER NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS "maxRetryCount" INTEGER NOT NULL DEFAULT 5;
-- Extend subscription_invoices table
ALTER TABLE "subscription_invoices"
ADD COLUMN IF NOT EXISTS "providerInvoiceId" TEXT,
ADD COLUMN IF NOT EXISTS "dueAt" TIMESTAMP(3),
ADD COLUMN IF NOT EXISTS "failedAt" TIMESTAMP(3),
ADD COLUMN IF NOT EXISTS "voidedAt" TIMESTAMP(3);
-- Create subscription_events table
CREATE TABLE IF NOT EXISTS "subscription_events" (
"id" TEXT NOT NULL,
"subscriptionId" TEXT NOT NULL,
"companyId" TEXT NOT NULL,
"eventType" TEXT NOT NULL,
"source" TEXT NOT NULL DEFAULT 'system',
"payload" JSONB NOT NULL DEFAULT '{}',
"occurredAt" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "subscription_events_pkey" PRIMARY KEY ("id")
);
CREATE INDEX IF NOT EXISTS "subscription_events_subscriptionId_idx" ON "subscription_events"("subscriptionId");
CREATE INDEX IF NOT EXISTS "subscription_events_companyId_idx" ON "subscription_events"("companyId");
ALTER TABLE "subscription_events"
ADD CONSTRAINT "subscription_events_subscriptionId_fkey"
FOREIGN KEY ("subscriptionId") REFERENCES "subscriptions"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- Create payment_attempts table
CREATE TABLE IF NOT EXISTS "payment_attempts" (
"id" TEXT NOT NULL,
"invoiceId" TEXT NOT NULL,
"subscriptionId" TEXT NOT NULL,
"providerPaymentId" TEXT,
"status" TEXT NOT NULL,
"failureCode" TEXT,
"failureMessage" TEXT,
"attemptedAt" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "payment_attempts_pkey" PRIMARY KEY ("id")
);
CREATE INDEX IF NOT EXISTS "payment_attempts_invoiceId_idx" ON "payment_attempts"("invoiceId");
ALTER TABLE "payment_attempts"
ADD CONSTRAINT "payment_attempts_invoiceId_fkey"
FOREIGN KEY ("invoiceId") REFERENCES "subscription_invoices"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,22 @@
CREATE TABLE IF NOT EXISTS "pricing_promotions" (
"id" TEXT NOT NULL,
"code" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"discountType" TEXT NOT NULL,
"discountValue" INTEGER NOT NULL,
"plans" TEXT[] NOT NULL DEFAULT '{}',
"periods" TEXT[] NOT NULL DEFAULT '{}',
"maxUses" INTEGER,
"usedCount" INTEGER NOT NULL DEFAULT 0,
"validFrom" TIMESTAMP(3) NOT NULL,
"validUntil" TIMESTAMP(3),
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"createdBy" TEXT,
CONSTRAINT "pricing_promotions_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX IF NOT EXISTS "pricing_promotions_code_key" ON "pricing_promotions"("code");
CREATE INDEX IF NOT EXISTS "pricing_promotions_isActive_idx" ON "pricing_promotions"("isActive");
@@ -0,0 +1,28 @@
CREATE TABLE "plan_features" (
"id" TEXT NOT NULL,
"plan" "Plan" NOT NULL,
"label" TEXT NOT NULL,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "plan_features_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "plan_features_plan_sortOrder_idx" ON "plan_features"("plan", "sortOrder");
INSERT INTO "plan_features" ("id", "plan", "label", "sortOrder") VALUES
('plf_starter_1', 'STARTER', 'Up to 10 vehicles', 10),
('plf_starter_2', 'STARTER', '1 user account', 20),
('plf_starter_3', 'STARTER', 'Basic analytics', 30),
('plf_starter_4', 'STARTER', 'Marketplace listing', 40),
('plf_growth_1', 'GROWTH', 'Up to 50 vehicles', 10),
('plf_growth_2', 'GROWTH', '5 user accounts', 20),
('plf_growth_3', 'GROWTH', 'Full analytics', 30),
('plf_growth_4', 'GROWTH', 'Priority marketplace placement', 40),
('plf_growth_5', 'GROWTH', 'Custom branding', 50),
('plf_pro_1', 'PRO', 'Unlimited vehicles', 10),
('plf_pro_2', 'PRO', 'Unlimited user accounts', 20),
('plf_pro_3', 'PRO', 'Advanced reports', 30),
('plf_pro_4', 'PRO', 'API access', 40),
('plf_pro_5', 'PRO', 'Dedicated support', 50);
@@ -0,0 +1,493 @@
CREATE TYPE "BillingInvoiceTerms" AS ENUM (
'DUE_ON_RECEIPT',
'NET_7',
'NET_15',
'NET_30',
'NET_45',
'NET_60'
);
CREATE TYPE "BillingInvoiceStatus" AS ENUM (
'DRAFT',
'OPEN',
'PAID',
'PARTIALLY_PAID',
'PAYMENT_PENDING',
'PAST_DUE',
'VOID',
'UNCOLLECTIBLE',
'REFUNDED',
'PARTIALLY_REFUNDED'
);
CREATE TYPE "BillingInvoiceType" AS ENUM (
'SUBSCRIPTION_INITIAL',
'SUBSCRIPTION_RENEWAL',
'TRIAL_CONVERSION',
'SUBSCRIPTION_UPGRADE',
'SUBSCRIPTION_DOWNGRADE_CREDIT',
'USAGE_BASED',
'ONE_TIME',
'MANUAL',
'CORRECTION',
'CANCELLATION_FEE'
);
CREATE TYPE "BillingLineItemType" AS ENUM (
'SUBSCRIPTION_FEE',
'SEAT_FEE',
'USAGE_FEE',
'SETUP_FEE',
'DISCOUNT',
'TAX',
'CREDIT',
'PRORATION',
'REFUND_ADJUSTMENT',
'MANUAL_ADJUSTMENT',
'PROFESSIONAL_SERVICES',
'CANCELLATION_FEE'
);
CREATE TYPE "BillingPaymentMethodType" AS ENUM (
'CARD',
'ACH_DEBIT',
'BANK_TRANSFER',
'WIRE_TRANSFER',
'MANUAL_INVOICE',
'PURCHASE_ORDER'
);
CREATE TYPE "BillingPaymentIntentStatus" AS ENUM (
'REQUIRES_PAYMENT_METHOD',
'REQUIRES_CONFIRMATION',
'REQUIRES_ACTION',
'PROCESSING',
'SUCCEEDED',
'FAILED',
'CANCELED',
'REFUNDED',
'PARTIALLY_REFUNDED'
);
CREATE TYPE "BillingPaymentAttemptStatus" AS ENUM (
'PENDING',
'PROCESSING',
'SUCCEEDED',
'FAILED',
'CANCELED',
'REFUNDED',
'PARTIALLY_REFUNDED'
);
CREATE TYPE "BillingRefundStatus" AS ENUM (
'PENDING',
'SUCCEEDED',
'FAILED'
);
CREATE TYPE "BillingCreditNoteStatus" AS ENUM (
'ISSUED',
'APPLIED',
'VOID'
);
CREATE SEQUENCE "billing_invoice_sequence_seq" START 1;
CREATE TABLE "billing_accounts" (
"id" TEXT NOT NULL,
"companyId" TEXT NOT NULL,
"isPrimary" BOOLEAN NOT NULL DEFAULT true,
"legalName" TEXT NOT NULL,
"billingEmail" TEXT NOT NULL,
"billingAddress" JSONB,
"taxId" TEXT,
"taxExempt" BOOLEAN NOT NULL DEFAULT false,
"defaultCurrency" TEXT NOT NULL DEFAULT 'MAD',
"defaultPaymentMethodId" TEXT,
"invoiceTerms" "BillingInvoiceTerms" NOT NULL DEFAULT 'DUE_ON_RECEIPT',
"netTermsDays" INTEGER NOT NULL DEFAULT 0,
"providerCustomerId" TEXT,
"dunningPaused" BOOLEAN NOT NULL DEFAULT false,
"dunningPausedAt" TIMESTAMP(3),
"dunningPausedBy" TEXT,
"metadata" JSONB NOT NULL DEFAULT '{}',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "billing_accounts_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "billing_payment_methods" (
"id" TEXT NOT NULL,
"billingAccountId" TEXT NOT NULL,
"type" "BillingPaymentMethodType" NOT NULL,
"providerPaymentMethodId" TEXT,
"label" TEXT,
"brand" TEXT,
"last4" TEXT,
"expMonth" INTEGER,
"expYear" INTEGER,
"isDefault" BOOLEAN NOT NULL DEFAULT false,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"metadata" JSONB NOT NULL DEFAULT '{}',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "billing_payment_methods_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "billing_invoices" (
"id" TEXT NOT NULL,
"billingAccountId" TEXT NOT NULL,
"companyId" TEXT NOT NULL,
"subscriptionId" TEXT,
"invoiceNumber" TEXT,
"invoiceSequence" INTEGER,
"invoiceType" "BillingInvoiceType" NOT NULL,
"status" "BillingInvoiceStatus" NOT NULL DEFAULT 'DRAFT',
"currency" TEXT NOT NULL DEFAULT 'MAD',
"subtotalAmount" INTEGER NOT NULL DEFAULT 0,
"discountAmount" INTEGER NOT NULL DEFAULT 0,
"creditAmount" INTEGER NOT NULL DEFAULT 0,
"taxAmount" INTEGER NOT NULL DEFAULT 0,
"totalAmount" INTEGER NOT NULL DEFAULT 0,
"amountPaid" INTEGER NOT NULL DEFAULT 0,
"amountDue" INTEGER NOT NULL DEFAULT 0,
"invoiceDate" TIMESTAMP(3),
"dueAt" TIMESTAMP(3),
"finalizedAt" TIMESTAMP(3),
"paidAt" TIMESTAMP(3),
"voidedAt" TIMESTAMP(3),
"markedUncollectibleAt" TIMESTAMP(3),
"billingName" TEXT,
"billingEmail" TEXT,
"billingAddress" JSONB,
"providerInvoiceId" TEXT,
"paymentProvider" "PaymentProvider",
"isSubscriptionBlocking" BOOLEAN NOT NULL DEFAULT false,
"adminReason" TEXT,
"metadata" JSONB NOT NULL DEFAULT '{}',
"createdByAdminId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "billing_invoices_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "billing_invoice_line_items" (
"id" TEXT NOT NULL,
"invoiceId" TEXT NOT NULL,
"subscriptionId" TEXT,
"plan" "Plan",
"type" "BillingLineItemType" NOT NULL,
"description" TEXT NOT NULL,
"quantity" INTEGER NOT NULL DEFAULT 1,
"unitAmount" INTEGER NOT NULL,
"amount" INTEGER NOT NULL,
"currency" TEXT NOT NULL DEFAULT 'MAD',
"periodStart" TIMESTAMP(3),
"periodEnd" TIMESTAMP(3),
"metadata" JSONB NOT NULL DEFAULT '{}',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "billing_invoice_line_items_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "billing_payment_intents" (
"id" TEXT NOT NULL,
"invoiceId" TEXT NOT NULL,
"billingAccountId" TEXT NOT NULL,
"paymentMethodId" TEXT,
"providerPaymentIntentId" TEXT,
"status" "BillingPaymentIntentStatus" NOT NULL DEFAULT 'REQUIRES_PAYMENT_METHOD',
"amount" INTEGER NOT NULL,
"currency" TEXT NOT NULL DEFAULT 'MAD',
"metadata" JSONB NOT NULL DEFAULT '{}',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "billing_payment_intents_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "billing_payment_attempts" (
"id" TEXT NOT NULL,
"invoiceId" TEXT NOT NULL,
"billingAccountId" TEXT NOT NULL,
"paymentIntentId" TEXT,
"paymentMethodId" TEXT,
"providerPaymentId" TEXT,
"status" "BillingPaymentAttemptStatus" NOT NULL,
"amount" INTEGER NOT NULL,
"currency" TEXT NOT NULL DEFAULT 'MAD',
"failureCode" TEXT,
"failureMessage" TEXT,
"attemptedAt" TIMESTAMP(3) NOT NULL,
"metadata" JSONB NOT NULL DEFAULT '{}',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "billing_payment_attempts_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "billing_credit_balances" (
"id" TEXT NOT NULL,
"billingAccountId" TEXT NOT NULL,
"currency" TEXT NOT NULL DEFAULT 'MAD',
"balanceAmount" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "billing_credit_balances_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "billing_credit_ledger_entries" (
"id" TEXT NOT NULL,
"billingAccountId" TEXT NOT NULL,
"invoiceId" TEXT,
"type" TEXT NOT NULL,
"amount" INTEGER NOT NULL,
"currency" TEXT NOT NULL DEFAULT 'MAD',
"reason" TEXT NOT NULL,
"createdBy" TEXT,
"metadata" JSONB NOT NULL DEFAULT '{}',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "billing_credit_ledger_entries_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "billing_credit_notes" (
"id" TEXT NOT NULL,
"invoiceId" TEXT NOT NULL,
"billingAccountId" TEXT NOT NULL,
"amount" INTEGER NOT NULL,
"currency" TEXT NOT NULL DEFAULT 'MAD',
"reason" TEXT NOT NULL,
"status" "BillingCreditNoteStatus" NOT NULL DEFAULT 'ISSUED',
"createdBy" TEXT,
"metadata" JSONB NOT NULL DEFAULT '{}',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "billing_credit_notes_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "billing_refunds" (
"id" TEXT NOT NULL,
"invoiceId" TEXT NOT NULL,
"paymentAttemptId" TEXT,
"billingAccountId" TEXT NOT NULL,
"amount" INTEGER NOT NULL,
"currency" TEXT NOT NULL DEFAULT 'MAD',
"reason" TEXT NOT NULL,
"status" "BillingRefundStatus" NOT NULL DEFAULT 'PENDING',
"providerRefundId" TEXT,
"createdBy" TEXT,
"metadata" JSONB NOT NULL DEFAULT '{}',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "billing_refunds_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "billing_tax_records" (
"id" TEXT NOT NULL,
"invoiceId" TEXT NOT NULL,
"jurisdiction" TEXT,
"taxRate" DOUBLE PRECISION,
"taxAmount" INTEGER NOT NULL,
"taxType" TEXT,
"taxExempt" BOOLEAN NOT NULL DEFAULT false,
"exemptionReason" TEXT,
"providerTaxId" TEXT,
"metadata" JSONB NOT NULL DEFAULT '{}',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "billing_tax_records_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "billing_events" (
"id" TEXT NOT NULL,
"billingAccountId" TEXT,
"invoiceId" TEXT,
"subscriptionId" TEXT,
"companyId" TEXT,
"eventType" TEXT NOT NULL,
"source" TEXT NOT NULL,
"payload" JSONB NOT NULL DEFAULT '{}',
"occurredAt" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "billing_events_pkey" PRIMARY KEY ("id")
);
ALTER TABLE "subscription_invoices"
ADD COLUMN "billingInvoiceId" TEXT;
CREATE UNIQUE INDEX "subscription_invoices_billingInvoiceId_key" ON "subscription_invoices"("billingInvoiceId");
CREATE UNIQUE INDEX "billing_invoices_invoiceNumber_key" ON "billing_invoices"("invoiceNumber");
CREATE UNIQUE INDEX "billing_invoices_invoiceSequence_key" ON "billing_invoices"("invoiceSequence");
CREATE UNIQUE INDEX "billing_credit_balances_billingAccountId_currency_key" ON "billing_credit_balances"("billingAccountId", "currency");
CREATE INDEX "billing_accounts_companyId_idx" ON "billing_accounts"("companyId");
CREATE INDEX "billing_accounts_companyId_isPrimary_idx" ON "billing_accounts"("companyId", "isPrimary");
CREATE INDEX "billing_payment_methods_billingAccountId_idx" ON "billing_payment_methods"("billingAccountId");
CREATE INDEX "billing_invoices_billingAccountId_idx" ON "billing_invoices"("billingAccountId");
CREATE INDEX "billing_invoices_companyId_idx" ON "billing_invoices"("companyId");
CREATE INDEX "billing_invoices_subscriptionId_idx" ON "billing_invoices"("subscriptionId");
CREATE INDEX "billing_invoice_line_items_invoiceId_idx" ON "billing_invoice_line_items"("invoiceId");
CREATE INDEX "billing_payment_intents_invoiceId_idx" ON "billing_payment_intents"("invoiceId");
CREATE INDEX "billing_payment_intents_billingAccountId_idx" ON "billing_payment_intents"("billingAccountId");
CREATE INDEX "billing_payment_attempts_invoiceId_idx" ON "billing_payment_attempts"("invoiceId");
CREATE INDEX "billing_payment_attempts_billingAccountId_idx" ON "billing_payment_attempts"("billingAccountId");
CREATE INDEX "billing_credit_ledger_entries_billingAccountId_idx" ON "billing_credit_ledger_entries"("billingAccountId");
CREATE INDEX "billing_credit_notes_invoiceId_idx" ON "billing_credit_notes"("invoiceId");
CREATE INDEX "billing_credit_notes_billingAccountId_idx" ON "billing_credit_notes"("billingAccountId");
CREATE INDEX "billing_refunds_invoiceId_idx" ON "billing_refunds"("invoiceId");
CREATE INDEX "billing_refunds_billingAccountId_idx" ON "billing_refunds"("billingAccountId");
CREATE INDEX "billing_tax_records_invoiceId_idx" ON "billing_tax_records"("invoiceId");
CREATE INDEX "billing_events_billingAccountId_idx" ON "billing_events"("billingAccountId");
CREATE INDEX "billing_events_invoiceId_idx" ON "billing_events"("invoiceId");
CREATE INDEX "billing_events_subscriptionId_idx" ON "billing_events"("subscriptionId");
CREATE INDEX "billing_events_companyId_idx" ON "billing_events"("companyId");
ALTER TABLE "billing_accounts"
ADD CONSTRAINT "billing_accounts_companyId_fkey"
FOREIGN KEY ("companyId") REFERENCES "companies"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "billing_payment_methods"
ADD CONSTRAINT "billing_payment_methods_billingAccountId_fkey"
FOREIGN KEY ("billingAccountId") REFERENCES "billing_accounts"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "billing_accounts"
ADD CONSTRAINT "billing_accounts_defaultPaymentMethodId_fkey"
FOREIGN KEY ("defaultPaymentMethodId") REFERENCES "billing_payment_methods"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "billing_invoices"
ADD CONSTRAINT "billing_invoices_billingAccountId_fkey"
FOREIGN KEY ("billingAccountId") REFERENCES "billing_accounts"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "billing_invoices"
ADD CONSTRAINT "billing_invoices_companyId_fkey"
FOREIGN KEY ("companyId") REFERENCES "companies"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "billing_invoices"
ADD CONSTRAINT "billing_invoices_subscriptionId_fkey"
FOREIGN KEY ("subscriptionId") REFERENCES "subscriptions"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "subscription_invoices"
ADD CONSTRAINT "subscription_invoices_billingInvoiceId_fkey"
FOREIGN KEY ("billingInvoiceId") REFERENCES "billing_invoices"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "billing_invoice_line_items"
ADD CONSTRAINT "billing_invoice_line_items_invoiceId_fkey"
FOREIGN KEY ("invoiceId") REFERENCES "billing_invoices"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "billing_payment_intents"
ADD CONSTRAINT "billing_payment_intents_invoiceId_fkey"
FOREIGN KEY ("invoiceId") REFERENCES "billing_invoices"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "billing_payment_intents"
ADD CONSTRAINT "billing_payment_intents_billingAccountId_fkey"
FOREIGN KEY ("billingAccountId") REFERENCES "billing_accounts"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "billing_payment_intents"
ADD CONSTRAINT "billing_payment_intents_paymentMethodId_fkey"
FOREIGN KEY ("paymentMethodId") REFERENCES "billing_payment_methods"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "billing_payment_attempts"
ADD CONSTRAINT "billing_payment_attempts_invoiceId_fkey"
FOREIGN KEY ("invoiceId") REFERENCES "billing_invoices"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "billing_payment_attempts"
ADD CONSTRAINT "billing_payment_attempts_billingAccountId_fkey"
FOREIGN KEY ("billingAccountId") REFERENCES "billing_accounts"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "billing_payment_attempts"
ADD CONSTRAINT "billing_payment_attempts_paymentIntentId_fkey"
FOREIGN KEY ("paymentIntentId") REFERENCES "billing_payment_intents"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "billing_payment_attempts"
ADD CONSTRAINT "billing_payment_attempts_paymentMethodId_fkey"
FOREIGN KEY ("paymentMethodId") REFERENCES "billing_payment_methods"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "billing_credit_balances"
ADD CONSTRAINT "billing_credit_balances_billingAccountId_fkey"
FOREIGN KEY ("billingAccountId") REFERENCES "billing_accounts"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "billing_credit_ledger_entries"
ADD CONSTRAINT "billing_credit_ledger_entries_billingAccountId_fkey"
FOREIGN KEY ("billingAccountId") REFERENCES "billing_accounts"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "billing_credit_ledger_entries"
ADD CONSTRAINT "billing_credit_ledger_entries_invoiceId_fkey"
FOREIGN KEY ("invoiceId") REFERENCES "billing_invoices"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "billing_credit_notes"
ADD CONSTRAINT "billing_credit_notes_invoiceId_fkey"
FOREIGN KEY ("invoiceId") REFERENCES "billing_invoices"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "billing_credit_notes"
ADD CONSTRAINT "billing_credit_notes_billingAccountId_fkey"
FOREIGN KEY ("billingAccountId") REFERENCES "billing_accounts"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "billing_refunds"
ADD CONSTRAINT "billing_refunds_invoiceId_fkey"
FOREIGN KEY ("invoiceId") REFERENCES "billing_invoices"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "billing_refunds"
ADD CONSTRAINT "billing_refunds_paymentAttemptId_fkey"
FOREIGN KEY ("paymentAttemptId") REFERENCES "billing_payment_attempts"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "billing_refunds"
ADD CONSTRAINT "billing_refunds_billingAccountId_fkey"
FOREIGN KEY ("billingAccountId") REFERENCES "billing_accounts"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "billing_tax_records"
ADD CONSTRAINT "billing_tax_records_invoiceId_fkey"
FOREIGN KEY ("invoiceId") REFERENCES "billing_invoices"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "billing_events"
ADD CONSTRAINT "billing_events_billingAccountId_fkey"
FOREIGN KEY ("billingAccountId") REFERENCES "billing_accounts"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "billing_events"
ADD CONSTRAINT "billing_events_invoiceId_fkey"
FOREIGN KEY ("invoiceId") REFERENCES "billing_invoices"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "billing_events"
ADD CONSTRAINT "billing_events_subscriptionId_fkey"
FOREIGN KEY ("subscriptionId") REFERENCES "subscriptions"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "billing_events"
ADD CONSTRAINT "billing_events_companyId_fkey"
FOREIGN KEY ("companyId") REFERENCES "companies"("id") ON DELETE SET NULL ON UPDATE CASCADE;
INSERT INTO "billing_accounts" (
"id",
"companyId",
"isPrimary",
"legalName",
"billingEmail",
"billingAddress",
"taxExempt",
"defaultCurrency",
"invoiceTerms",
"netTermsDays",
"metadata"
)
SELECT
'ba_' || c."id",
c."id",
true,
c."name",
c."email",
c."address",
false,
COALESCE(a."currency", 'MAD'),
'DUE_ON_RECEIPT'::"BillingInvoiceTerms",
0,
'{}'::jsonb
FROM "companies" c
LEFT JOIN "accounting_settings" a ON a."companyId" = c."id"
WHERE NOT EXISTS (
SELECT 1
FROM "billing_accounts" ba
WHERE ba."companyId" = c."id" AND ba."isPrimary" = true
);
@@ -0,0 +1,210 @@
ALTER TYPE "NotificationType" ADD VALUE IF NOT EXISTS 'ACCOUNT_CREATED';
ALTER TABLE "billing_accounts"
ADD COLUMN "preferredLanguage" TEXT NOT NULL DEFAULT 'en';
ALTER TABLE "notifications"
ADD COLUMN "templateKey" TEXT,
ADD COLUMN "locale" TEXT NOT NULL DEFAULT 'en';
CREATE TABLE "notification_templates" (
"id" TEXT NOT NULL,
"templateKey" TEXT NOT NULL,
"category" TEXT NOT NULL,
"channel" "NotificationChannel" NOT NULL,
"locale" TEXT NOT NULL,
"subject" TEXT,
"body" TEXT NOT NULL,
"requiredVariables" JSONB,
"optionalVariables" JSONB,
"version" INTEGER NOT NULL DEFAULT 1,
"isActive" BOOLEAN NOT NULL DEFAULT TRUE,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "notification_templates_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "notification_templates_templateKey_channel_locale_version_key"
ON "notification_templates"("templateKey", "channel", "locale", "version");
CREATE INDEX "notification_templates_templateKey_channel_locale_isActive_idx"
ON "notification_templates"("templateKey", "channel", "locale", "isActive");
INSERT INTO "notification_templates" (
"id",
"templateKey",
"category",
"channel",
"locale",
"subject",
"body",
"requiredVariables",
"optionalVariables"
)
VALUES
(
'notif_tpl_account_created_email_en',
'account.created',
'account',
'EMAIL',
'en',
'Your workspace is ready - RentalDriveGo',
E'Hi {{firstName}},\n\nYour RentalDriveGo workspace for {{companyName}} has been created successfully.\nPlan: {{planName}} ({{billingPeriodLabel}})\nCurrency: {{currency}}\nPrimary payment provider: {{paymentProvider}}\nFree trial ends on {{trialEndDate}}.\n\nYour workspace is ready. Sign in with the email and password you chose during signup.\n\nRentalDriveGo',
'["firstName","companyName","planName","billingPeriodLabel","currency","paymentProvider","trialEndDate"]'::jsonb,
'[]'::jsonb
),
(
'notif_tpl_account_created_email_fr',
'account.created',
'account',
'EMAIL',
'fr',
'Votre espace de travail est pret - RentalDriveGo',
E'Bonjour {{firstName}},\n\nVotre espace de travail RentalDriveGo pour {{companyName}} a ete cree avec succes.\nForfait : {{planName}} ({{billingPeriodLabel}})\nDevise : {{currency}}\nFournisseur de paiement principal : {{paymentProvider}}\nLa periode d''essai gratuit se termine le {{trialEndDate}}.\n\nVotre espace de travail est pret. Connectez-vous avec l''e-mail et le mot de passe choisis lors de l''inscription.\n\nRentalDriveGo',
'["firstName","companyName","planName","billingPeriodLabel","currency","paymentProvider","trialEndDate"]'::jsonb,
'[]'::jsonb
),
(
'notif_tpl_account_created_email_ar',
'account.created',
'account',
'EMAIL',
'ar',
'مساحة عملك جاهزة - RentalDriveGo',
E'مرحباً {{firstName}}،\n\nتم إنشاء مساحة عمل RentalDriveGo الخاصة بـ {{companyName}} بنجاح.\nالخطة: {{planName}} ({{billingPeriodLabel}})\nالعملة: {{currency}}\nمزود الدفع الرئيسي: {{paymentProvider}}\nتنتهي الفترة التجريبية المجانية في {{trialEndDate}}.\n\nمساحة عملك جاهزة. سجّل الدخول باستخدام البريد الإلكتروني وكلمة المرور التي اخترتهما عند التسجيل.\n\nRentalDriveGo',
'["firstName","companyName","planName","billingPeriodLabel","currency","paymentProvider","trialEndDate"]'::jsonb,
'[]'::jsonb
),
(
'notif_tpl_account_created_in_app_en',
'account.created',
'account',
'IN_APP',
'en',
'Workspace ready',
E'Your RentalDriveGo workspace for {{companyName}} is ready. Your free trial ends on {{trialEndDate}}.',
'["companyName","trialEndDate"]'::jsonb,
'[]'::jsonb
),
(
'notif_tpl_account_created_in_app_fr',
'account.created',
'account',
'IN_APP',
'fr',
'Espace pret',
E'Votre espace de travail RentalDriveGo pour {{companyName}} est pret. Votre essai gratuit se termine le {{trialEndDate}}.',
'["companyName","trialEndDate"]'::jsonb,
'[]'::jsonb
),
(
'notif_tpl_account_created_in_app_ar',
'account.created',
'account',
'IN_APP',
'ar',
'مساحة العمل جاهزة',
E'مساحة عمل RentalDriveGo الخاصة بـ {{companyName}} جاهزة. تنتهي الفترة التجريبية المجانية في {{trialEndDate}}.',
'["companyName","trialEndDate"]'::jsonb,
'[]'::jsonb
),
(
'notif_tpl_booking_confirmed_email_en',
'booking.confirmed',
'reservation',
'EMAIL',
'en',
'Booking confirmed - {{companyName}}',
E'Hi {{firstName}},\n\nYour booking with {{companyName}} has been confirmed.\nPickup: {{startDate}}\nReturn: {{endDate}}\nVehicle: {{vehicleName}}\n\nThank you for choosing RentalDriveGo.',
'["firstName","companyName","startDate","endDate","vehicleName"]'::jsonb,
'[]'::jsonb
),
(
'notif_tpl_booking_confirmed_email_fr',
'booking.confirmed',
'reservation',
'EMAIL',
'fr',
'Reservation confirmee - {{companyName}}',
E'Bonjour {{firstName}},\n\nVotre reservation chez {{companyName}} a ete confirmee.\nPrise en charge : {{startDate}}\nRetour : {{endDate}}\nVehicule : {{vehicleName}}\n\nMerci d''avoir choisi RentalDriveGo.',
'["firstName","companyName","startDate","endDate","vehicleName"]'::jsonb,
'[]'::jsonb
),
(
'notif_tpl_booking_confirmed_email_ar',
'booking.confirmed',
'reservation',
'EMAIL',
'ar',
'تم تأكيد الحجز - {{companyName}}',
E'مرحباً {{firstName}}،\n\nتم تأكيد حجزك مع {{companyName}}.\nالاستلام: {{startDate}}\nالإرجاع: {{endDate}}\nالمركبة: {{vehicleName}}\n\nشكراً لاختيار RentalDriveGo.',
'["firstName","companyName","startDate","endDate","vehicleName"]'::jsonb,
'[]'::jsonb
),
(
'notif_tpl_booking_confirmed_in_app_en',
'booking.confirmed',
'reservation',
'IN_APP',
'en',
'Booking confirmed',
E'Your booking with {{companyName}} has been confirmed.',
'["companyName"]'::jsonb,
'[]'::jsonb
),
(
'notif_tpl_booking_confirmed_in_app_fr',
'booking.confirmed',
'reservation',
'IN_APP',
'fr',
'Reservation confirmee',
E'Votre reservation chez {{companyName}} a ete confirmee.',
'["companyName"]'::jsonb,
'[]'::jsonb
),
(
'notif_tpl_booking_confirmed_in_app_ar',
'booking.confirmed',
'reservation',
'IN_APP',
'ar',
'تم تأكيد الحجز',
E'تم تأكيد حجزك مع {{companyName}}.',
'["companyName"]'::jsonb,
'[]'::jsonb
),
(
'notif_tpl_subscription_trial_ending_in_app_en',
'subscription.trial_ending',
'subscription',
'IN_APP',
'en',
'Your trial ends soon',
E'Your free trial ends on {{trialEndDate}}. Add a payment method to keep access.',
'["trialEndDate"]'::jsonb,
'[]'::jsonb
),
(
'notif_tpl_subscription_trial_ending_in_app_fr',
'subscription.trial_ending',
'subscription',
'IN_APP',
'fr',
'Votre essai se termine bientot',
E'Votre essai gratuit se termine le {{trialEndDate}}. Ajoutez un moyen de paiement pour conserver l''acces.',
'["trialEndDate"]'::jsonb,
'[]'::jsonb
),
(
'notif_tpl_subscription_trial_ending_in_app_ar',
'subscription.trial_ending',
'subscription',
'IN_APP',
'ar',
'ستنتهي الفترة التجريبية قريباً',
E'تنتهي فترتك التجريبية المجانية في {{trialEndDate}}. أضف وسيلة دفع للحفاظ على الوصول.',
'["trialEndDate"]'::jsonb,
'[]'::jsonb
);
@@ -0,0 +1,26 @@
UPDATE "notification_templates"
SET
"body" = E'Hi {{firstName}},\n\nYour booking with {{companyName}} has been confirmed.\nPickup details: {{startDate}}\nPickup address: {{pickupLocation}}\nReturn: {{endDate}}\nVehicle: {{vehicleName}}\n\nThank you for choosing RentalDriveGo.',
"requiredVariables" = '["firstName","companyName","startDate","pickupLocation","endDate","vehicleName"]'::jsonb,
"updatedAt" = CURRENT_TIMESTAMP
WHERE "templateKey" = 'booking.confirmed'
AND "channel" = 'EMAIL'
AND "locale" = 'en';
UPDATE "notification_templates"
SET
"body" = E'Bonjour {{firstName}},\n\nVotre reservation chez {{companyName}} a ete confirmee.\nDetails de prise en charge : {{startDate}}\nAdresse de prise en charge : {{pickupLocation}}\nRetour : {{endDate}}\nVehicule : {{vehicleName}}\n\nMerci d''avoir choisi RentalDriveGo.',
"requiredVariables" = '["firstName","companyName","startDate","pickupLocation","endDate","vehicleName"]'::jsonb,
"updatedAt" = CURRENT_TIMESTAMP
WHERE "templateKey" = 'booking.confirmed'
AND "channel" = 'EMAIL'
AND "locale" = 'fr';
UPDATE "notification_templates"
SET
"body" = E'مرحباً {{firstName}}،\n\nتم تأكيد حجزك مع {{companyName}}.\nتفاصيل الاستلام: {{startDate}}\nعنوان الاستلام: {{pickupLocation}}\nالإرجاع: {{endDate}}\nالمركبة: {{vehicleName}}\n\nشكراً لاختيار RentalDriveGo.',
"requiredVariables" = '["firstName","companyName","startDate","pickupLocation","endDate","vehicleName"]'::jsonb,
"updatedAt" = CURRENT_TIMESTAMP
WHERE "templateKey" = 'booking.confirmed'
AND "channel" = 'EMAIL'
AND "locale" = 'ar';
@@ -0,0 +1,19 @@
-- CreateEnum
CREATE TYPE "ReservationPhotoType" AS ENUM ('PICKUP', 'DROPOFF');
-- CreateTable
CREATE TABLE "reservation_photos" (
"id" TEXT NOT NULL,
"reservationId" TEXT NOT NULL,
"type" "ReservationPhotoType" NOT NULL,
"url" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "reservation_photos_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "reservation_photos_reservationId_idx" ON "reservation_photos"("reservationId");
-- AddForeignKey
ALTER TABLE "reservation_photos" ADD CONSTRAINT "reservation_photos_reservationId_fkey" FOREIGN KEY ("reservationId") REFERENCES "reservations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,6 @@
-- AlterEnum (PostgreSQL enum values must be added outside a transaction)
ALTER TYPE "VehicleStatus" ADD VALUE 'RESERVED';
ALTER TYPE "VehicleStatus" ADD VALUE 'READY';
ALTER TYPE "VehicleStatus" ADD VALUE 'RETURNED';
ALTER TYPE "VehicleStatus" ADD VALUE 'NEEDS_CLEANING';
ALTER TYPE "VehicleStatus" ADD VALUE 'DAMAGE_REVIEW';
@@ -0,0 +1,83 @@
-- CreateEnum: FeedbackCategory
CREATE TYPE "FeedbackCategory" AS ENUM (
'BOOKING',
'PICKUP',
'VEHICLE_CLEANLINESS',
'VEHICLE_CONDITION',
'STAFF_SERVICE',
'PRICING',
'DEPOSIT',
'INSURANCE',
'DAMAGE_CLAIM',
'RETURN_PROCESS',
'COMMUNICATION',
'ROADSIDE_ASSISTANCE',
'BILLING',
'OTHER'
);
-- CreateEnum: ComplaintSeverity
CREATE TYPE "ComplaintSeverity" AS ENUM ('LEVEL_1', 'LEVEL_2', 'LEVEL_3');
-- CreateEnum: ComplaintStatus
CREATE TYPE "ComplaintStatus" AS ENUM ('OPEN', 'INVESTIGATING', 'RESOLVED', 'CLOSED');
-- AlterTable: reviews — add category column
ALTER TABLE "reviews" ADD COLUMN "category" "FeedbackCategory";
-- AlterTable: reservations — add review tracking columns
ALTER TABLE "reservations"
ADD COLUMN "reviewRequestSentAt" TIMESTAMP(3),
ADD COLUMN "reviewReminderSentAt" TIMESTAMP(3),
ADD COLUMN "reviewFinalReminderSentAt" TIMESTAMP(3),
ADD COLUMN "reviewPaused" BOOLEAN NOT NULL DEFAULT false;
-- AlterTable: customers — add reviewOptOut column
ALTER TABLE "customers" ADD COLUMN "reviewOptOut" BOOLEAN NOT NULL DEFAULT false;
-- CreateTable: complaints
CREATE TABLE "complaints" (
"id" TEXT NOT NULL,
"companyId" TEXT NOT NULL,
"reservationId" TEXT,
"reviewId" TEXT,
"customerId" TEXT,
"severity" "ComplaintSeverity" NOT NULL DEFAULT 'LEVEL_1',
"status" "ComplaintStatus" NOT NULL DEFAULT 'OPEN',
"category" "FeedbackCategory" NOT NULL,
"subject" TEXT NOT NULL,
"description" TEXT,
"resolution" TEXT,
"notes" TEXT,
"assignedTo" TEXT,
"resolvedAt" TIMESTAMP(3),
"resolvedBy" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "complaints_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "complaints_companyId_idx" ON "complaints"("companyId");
CREATE INDEX "complaints_reservationId_idx" ON "complaints"("reservationId");
-- AddForeignKey: complaints → companies
ALTER TABLE "complaints"
ADD CONSTRAINT "complaints_companyId_fkey"
FOREIGN KEY ("companyId") REFERENCES "companies"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey: complaints → reservations
ALTER TABLE "complaints"
ADD CONSTRAINT "complaints_reservationId_fkey"
FOREIGN KEY ("reservationId") REFERENCES "reservations"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey: complaints → reviews
ALTER TABLE "complaints"
ADD CONSTRAINT "complaints_reviewId_fkey"
FOREIGN KEY ("reviewId") REFERENCES "reviews"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey: complaints → customers
ALTER TABLE "complaints"
ADD CONSTRAINT "complaints_customerId_fkey"
FOREIGN KEY ("customerId") REFERENCES "customers"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,107 @@
CREATE TYPE "VehiclePricingMode" AS ENUM ('MANUAL', 'AUTOMATIC');
CREATE TYPE "VehiclePricingRuleType" AS ENUM (
'DATE_RANGE',
'SEASONAL',
'HOLIDAY',
'WEEKEND',
'SPECIAL_EVENT',
'MANUAL_OVERRIDE'
);
CREATE TYPE "VehiclePriceChangeSource" AS ENUM (
'CONFIG_UPDATE',
'RULE_CREATED',
'RULE_UPDATED',
'RULE_DELETED',
'MANUAL_OVERRIDE'
);
CREATE TABLE "vehicle_pricing_configurations" (
"id" TEXT NOT NULL,
"vehicleId" TEXT NOT NULL,
"pricingMode" "VehiclePricingMode" NOT NULL DEFAULT 'MANUAL',
"baseDailyRate" INTEGER,
"weeklyRate" INTEGER,
"weekendRate" INTEGER,
"holidayRate" INTEGER,
"monthlyRate" INTEGER,
"longTermDailyRate" INTEGER,
"minimumDailyRate" INTEGER,
"maximumDailyRate" INTEGER,
"maxDailyPriceMovementPct" INTEGER NOT NULL DEFAULT 10,
"automaticPricingEnabled" BOOLEAN NOT NULL DEFAULT false,
"approvalRequired" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "vehicle_pricing_configurations_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "vehicle_pricing_configurations_vehicleId_key"
ON "vehicle_pricing_configurations"("vehicleId");
CREATE TABLE "vehicle_pricing_rules" (
"id" TEXT NOT NULL,
"configurationId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"ruleType" "VehiclePricingRuleType" NOT NULL DEFAULT 'DATE_RANGE',
"startDate" TIMESTAMP(3) NOT NULL,
"endDate" TIMESTAMP(3) NOT NULL,
"dailyRate" INTEGER,
"weeklyRate" INTEGER,
"weekendRate" INTEGER,
"monthlyRate" INTEGER,
"minDailyRate" INTEGER,
"maxDailyRate" INTEGER,
"automaticAdjustmentPct" INTEGER,
"priceAdjustment" INTEGER,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "vehicle_pricing_rules_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "vehicle_pricing_rules_configurationId_idx"
ON "vehicle_pricing_rules"("configurationId");
CREATE INDEX "vehicle_pricing_rules_configurationId_startDate_endDate_idx"
ON "vehicle_pricing_rules"("configurationId", "startDate", "endDate");
CREATE TABLE "vehicle_price_history" (
"id" TEXT NOT NULL,
"configurationId" TEXT NOT NULL,
"vehicleId" TEXT NOT NULL,
"source" "VehiclePriceChangeSource" NOT NULL DEFAULT 'CONFIG_UPDATE',
"changedByEmployeeId" TEXT,
"previousDailyRate" INTEGER,
"nextDailyRate" INTEGER,
"previousWeeklyRate" INTEGER,
"nextWeeklyRate" INTEGER,
"note" TEXT,
"effectiveFrom" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "vehicle_price_history_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "vehicle_price_history_vehicleId_createdAt_idx"
ON "vehicle_price_history"("vehicleId", "createdAt");
CREATE INDEX "vehicle_price_history_configurationId_createdAt_idx"
ON "vehicle_price_history"("configurationId", "createdAt");
ALTER TABLE "vehicle_pricing_configurations"
ADD CONSTRAINT "vehicle_pricing_configurations_vehicleId_fkey"
FOREIGN KEY ("vehicleId") REFERENCES "vehicles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "vehicle_pricing_rules"
ADD CONSTRAINT "vehicle_pricing_rules_configurationId_fkey"
FOREIGN KEY ("configurationId") REFERENCES "vehicle_pricing_configurations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "vehicle_price_history"
ADD CONSTRAINT "vehicle_price_history_configurationId_fkey"
FOREIGN KEY ("configurationId") REFERENCES "vehicle_pricing_configurations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "vehicle_price_history"
ADD CONSTRAINT "vehicle_price_history_vehicleId_fkey"
FOREIGN KEY ("vehicleId") REFERENCES "vehicles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,129 @@
-- CreateEnum
CREATE TYPE "MenuItemType" AS ENUM (
'INTERNAL_PAGE',
'EXTERNAL_LINK',
'PARENT_MENU',
'SECTION_LABEL',
'DIVIDER'
);
-- CreateTable
CREATE TABLE "menu_items" (
"id" TEXT NOT NULL,
"systemKey" TEXT,
"label" TEXT NOT NULL,
"itemType" "MenuItemType" NOT NULL DEFAULT 'INTERNAL_PAGE',
"routeOrUrl" TEXT,
"icon" TEXT,
"parentId" TEXT,
"displayOrder" INTEGER NOT NULL DEFAULT 0,
"openInNewTab" BOOLEAN NOT NULL DEFAULT false,
"isRequired" BOOLEAN NOT NULL DEFAULT false,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdBy" TEXT,
"updatedBy" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "menu_items_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "subscription_menu_items" (
"id" TEXT NOT NULL,
"plan" "Plan" NOT NULL,
"menuItemId" TEXT NOT NULL,
"displayOrder" INTEGER NOT NULL DEFAULT 0,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "subscription_menu_items_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "company_menu_items" (
"id" TEXT NOT NULL,
"companyId" TEXT NOT NULL,
"menuItemId" TEXT NOT NULL,
"displayOrder" INTEGER NOT NULL DEFAULT 0,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "company_menu_items_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "menu_item_role_visibility" (
"id" TEXT NOT NULL,
"menuItemId" TEXT NOT NULL,
"role" "EmployeeRole" NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "menu_item_role_visibility_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "menu_items_systemKey_key" ON "menu_items"("systemKey");
-- CreateIndex
CREATE INDEX "menu_items_parentId_idx" ON "menu_items"("parentId");
-- CreateIndex
CREATE INDEX "menu_items_isActive_displayOrder_idx" ON "menu_items"("isActive", "displayOrder");
-- CreateIndex
CREATE UNIQUE INDEX "subscription_menu_items_plan_menuItemId_key" ON "subscription_menu_items"("plan", "menuItemId");
-- CreateIndex
CREATE INDEX "subscription_menu_items_menuItemId_idx" ON "subscription_menu_items"("menuItemId");
-- CreateIndex
CREATE INDEX "subscription_menu_items_plan_displayOrder_idx" ON "subscription_menu_items"("plan", "displayOrder");
-- CreateIndex
CREATE UNIQUE INDEX "company_menu_items_companyId_menuItemId_key" ON "company_menu_items"("companyId", "menuItemId");
-- CreateIndex
CREATE INDEX "company_menu_items_menuItemId_idx" ON "company_menu_items"("menuItemId");
-- CreateIndex
CREATE INDEX "company_menu_items_companyId_displayOrder_idx" ON "company_menu_items"("companyId", "displayOrder");
-- CreateIndex
CREATE UNIQUE INDEX "menu_item_role_visibility_menuItemId_role_key" ON "menu_item_role_visibility"("menuItemId", "role");
-- CreateIndex
CREATE INDEX "menu_item_role_visibility_role_idx" ON "menu_item_role_visibility"("role");
-- AddForeignKey
ALTER TABLE "menu_items"
ADD CONSTRAINT "menu_items_parentId_fkey"
FOREIGN KEY ("parentId") REFERENCES "menu_items"("id")
ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "subscription_menu_items"
ADD CONSTRAINT "subscription_menu_items_menuItemId_fkey"
FOREIGN KEY ("menuItemId") REFERENCES "menu_items"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "company_menu_items"
ADD CONSTRAINT "company_menu_items_companyId_fkey"
FOREIGN KEY ("companyId") REFERENCES "companies"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "company_menu_items"
ADD CONSTRAINT "company_menu_items_menuItemId_fkey"
FOREIGN KEY ("menuItemId") REFERENCES "menu_items"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "menu_item_role_visibility"
ADD CONSTRAINT "menu_item_role_visibility_menuItemId_fkey"
FOREIGN KEY ("menuItemId") REFERENCES "menu_items"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,215 @@
WITH canonical_menu_items ("systemKey", "label", "itemType", "routeOrUrl", "icon", "displayOrder", "isRequired", "isActive") AS (
VALUES
('dashboard', 'Dashboard', 'INTERNAL_PAGE'::"MenuItemType", '/', 'LayoutDashboard', 10, true, true),
('fleet', 'Fleet', 'INTERNAL_PAGE'::"MenuItemType", '/fleet', 'Car', 20, true, true),
('reservations', 'Booking', 'INTERNAL_PAGE'::"MenuItemType", '/reservations', 'Calendar', 30, true, true),
('customers', 'Customers', 'INTERNAL_PAGE'::"MenuItemType", '/customers', 'Users', 40, false, true),
('subscription', 'Subscription', 'INTERNAL_PAGE'::"MenuItemType", '/subscription', 'CreditCard', 50, true, true),
('contracts', 'Contracts', 'INTERNAL_PAGE'::"MenuItemType", '/contracts', 'FileText', 60, false, true),
('notifications', 'Notifications', 'INTERNAL_PAGE'::"MenuItemType", '/notifications', 'Bell', 70, false, true),
('settings', 'Settings', 'INTERNAL_PAGE'::"MenuItemType", '/settings', 'Settings', 80, false, true),
('onlineReservations', 'Online Reservations', 'INTERNAL_PAGE'::"MenuItemType", '/online-reservations', 'Globe', 90, false, true),
('team', 'Team', 'INTERNAL_PAGE'::"MenuItemType", '/team', 'UserPlus', 100, false, true),
('offers', 'Offers', 'INTERNAL_PAGE'::"MenuItemType", '/offers', 'Tag', 110, false, true),
('reports', 'Reports', 'INTERNAL_PAGE'::"MenuItemType", '/reports', 'BarChart2', 120, false, true),
('billing', 'Customer Billing', 'INTERNAL_PAGE'::"MenuItemType", '/billing', 'CreditCard', 130, false, true),
('reviews', 'Reviews', 'INTERNAL_PAGE'::"MenuItemType", '/reviews', 'Star', 140, false, true),
('complaints', 'Complaints', 'INTERNAL_PAGE'::"MenuItemType", '/complaints', 'AlertTriangle', 150, false, true)
)
INSERT INTO "menu_items" (
"id",
"systemKey",
"label",
"itemType",
"routeOrUrl",
"icon",
"displayOrder",
"openInNewTab",
"isRequired",
"isActive",
"createdBy",
"updatedBy"
)
SELECT
'menu_' || "systemKey",
"systemKey",
"label",
"itemType",
"routeOrUrl",
"icon",
"displayOrder",
false,
"isRequired",
"isActive",
'system',
'system'
FROM canonical_menu_items
ON CONFLICT ("systemKey") DO UPDATE
SET
"label" = EXCLUDED."label",
"itemType" = EXCLUDED."itemType",
"routeOrUrl" = EXCLUDED."routeOrUrl",
"icon" = EXCLUDED."icon",
"displayOrder" = EXCLUDED."displayOrder",
"isRequired" = EXCLUDED."isRequired",
"isActive" = EXCLUDED."isActive",
"updatedBy" = 'system',
"updatedAt" = CURRENT_TIMESTAMP;
DELETE FROM "subscription_menu_items"
WHERE "menuItemId" IN (
SELECT "id"
FROM "menu_items"
WHERE "systemKey" IN (
'dashboard',
'fleet',
'reservations',
'customers',
'subscription',
'contracts',
'notifications',
'settings',
'onlineReservations',
'team',
'offers',
'reports',
'billing',
'reviews',
'complaints'
)
);
WITH canonical_subscription_assignments ("systemKey", "plan", "displayOrder") AS (
VALUES
('dashboard', 'STARTER'::"Plan", 10),
('fleet', 'STARTER'::"Plan", 20),
('reservations', 'STARTER'::"Plan", 30),
('customers', 'STARTER'::"Plan", 40),
('subscription', 'STARTER'::"Plan", 50),
('contracts', 'STARTER'::"Plan", 60),
('notifications', 'STARTER'::"Plan", 70),
('settings', 'STARTER'::"Plan", 80),
('dashboard', 'GROWTH'::"Plan", 10),
('fleet', 'GROWTH'::"Plan", 20),
('reservations', 'GROWTH'::"Plan", 30),
('customers', 'GROWTH'::"Plan", 40),
('subscription', 'GROWTH'::"Plan", 50),
('contracts', 'GROWTH'::"Plan", 60),
('notifications', 'GROWTH'::"Plan", 70),
('settings', 'GROWTH'::"Plan", 80),
('onlineReservations', 'GROWTH'::"Plan", 90),
('team', 'GROWTH'::"Plan", 100),
('offers', 'GROWTH'::"Plan", 110),
('reports', 'GROWTH'::"Plan", 120),
('billing', 'GROWTH'::"Plan", 130),
('reviews', 'GROWTH'::"Plan", 140),
('complaints', 'GROWTH'::"Plan", 150),
('dashboard', 'PRO'::"Plan", 10),
('fleet', 'PRO'::"Plan", 20),
('reservations', 'PRO'::"Plan", 30),
('customers', 'PRO'::"Plan", 40),
('subscription', 'PRO'::"Plan", 50),
('contracts', 'PRO'::"Plan", 60),
('notifications', 'PRO'::"Plan", 70),
('settings', 'PRO'::"Plan", 80),
('onlineReservations', 'PRO'::"Plan", 90),
('team', 'PRO'::"Plan", 100),
('offers', 'PRO'::"Plan", 110),
('reports', 'PRO'::"Plan", 120),
('billing', 'PRO'::"Plan", 130),
('reviews', 'PRO'::"Plan", 140),
('complaints', 'PRO'::"Plan", 150)
)
INSERT INTO "subscription_menu_items" (
"id",
"plan",
"menuItemId",
"displayOrder",
"isActive"
)
SELECT
'menu_sub_' || lower(csa."plan"::text) || '_' || csa."systemKey",
csa."plan",
mi."id",
csa."displayOrder",
true
FROM canonical_subscription_assignments csa
JOIN "menu_items" mi ON mi."systemKey" = csa."systemKey";
DELETE FROM "menu_item_role_visibility"
WHERE "menuItemId" IN (
SELECT "id"
FROM "menu_items"
WHERE "systemKey" IN (
'dashboard',
'fleet',
'reservations',
'customers',
'subscription',
'contracts',
'notifications',
'settings',
'onlineReservations',
'team',
'offers',
'reports',
'billing',
'reviews',
'complaints'
)
);
WITH canonical_role_visibilities ("systemKey", "role") AS (
VALUES
('dashboard', 'OWNER'::"EmployeeRole"),
('dashboard', 'MANAGER'::"EmployeeRole"),
('dashboard', 'AGENT'::"EmployeeRole"),
('fleet', 'OWNER'::"EmployeeRole"),
('fleet', 'MANAGER'::"EmployeeRole"),
('fleet', 'AGENT'::"EmployeeRole"),
('reservations', 'OWNER'::"EmployeeRole"),
('reservations', 'MANAGER'::"EmployeeRole"),
('reservations', 'AGENT'::"EmployeeRole"),
('customers', 'OWNER'::"EmployeeRole"),
('customers', 'MANAGER'::"EmployeeRole"),
('customers', 'AGENT'::"EmployeeRole"),
('subscription', 'OWNER'::"EmployeeRole"),
('contracts', 'OWNER'::"EmployeeRole"),
('contracts', 'MANAGER'::"EmployeeRole"),
('contracts', 'AGENT'::"EmployeeRole"),
('notifications', 'OWNER'::"EmployeeRole"),
('notifications', 'MANAGER'::"EmployeeRole"),
('notifications', 'AGENT'::"EmployeeRole"),
('settings', 'OWNER'::"EmployeeRole"),
('onlineReservations', 'OWNER'::"EmployeeRole"),
('onlineReservations', 'MANAGER'::"EmployeeRole"),
('onlineReservations', 'AGENT'::"EmployeeRole"),
('team', 'OWNER'::"EmployeeRole"),
('team', 'MANAGER'::"EmployeeRole"),
('team', 'AGENT'::"EmployeeRole"),
('offers', 'OWNER'::"EmployeeRole"),
('offers', 'MANAGER'::"EmployeeRole"),
('reports', 'OWNER'::"EmployeeRole"),
('reports', 'MANAGER'::"EmployeeRole"),
('billing', 'OWNER'::"EmployeeRole"),
('billing', 'MANAGER'::"EmployeeRole"),
('reviews', 'OWNER'::"EmployeeRole"),
('reviews', 'MANAGER'::"EmployeeRole"),
('reviews', 'AGENT'::"EmployeeRole"),
('complaints', 'OWNER'::"EmployeeRole"),
('complaints', 'MANAGER'::"EmployeeRole"),
('complaints', 'AGENT'::"EmployeeRole")
)
INSERT INTO "menu_item_role_visibility" (
"id",
"menuItemId",
"role"
)
SELECT
'menu_role_' || lower(crv."role"::text) || '_' || crv."systemKey",
mi."id",
crv."role"
FROM canonical_role_visibilities crv
JOIN "menu_items" mi ON mi."systemKey" = crv."systemKey";
@@ -0,0 +1,15 @@
-- Company API keys are stored hash-only. Raw keys are shown once at creation.
CREATE TABLE IF NOT EXISTS "company_api_keys" (
"id" TEXT PRIMARY KEY,
"companyId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"prefix" TEXT NOT NULL UNIQUE,
"keyHash" TEXT NOT NULL,
"lastUsedAt" TIMESTAMP(3),
"revokedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "company_api_keys_companyId_fkey"
FOREIGN KEY ("companyId") REFERENCES "companies"("id") ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE INDEX IF NOT EXISTS "company_api_keys_companyId_idx" ON "company_api_keys"("companyId");
@@ -0,0 +1,13 @@
CREATE TABLE IF NOT EXISTS "reservation_public_access" (
"id" TEXT PRIMARY KEY,
"reservationId" TEXT NOT NULL,
"tokenHash" TEXT NOT NULL UNIQUE,
"expiresAt" TIMESTAMP(3) NOT NULL,
"usedAt" TIMESTAMP(3),
"revokedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "reservation_public_access_reservationId_fkey"
FOREIGN KEY ("reservationId") REFERENCES "reservations"("id") ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE INDEX IF NOT EXISTS "reservation_public_access_reservationId_idx" ON "reservation_public_access"("reservationId");
@@ -0,0 +1,18 @@
CREATE TABLE IF NOT EXISTS "webhook_events" (
"id" TEXT NOT NULL,
"provider" TEXT NOT NULL,
"providerEventId" TEXT NOT NULL,
"eventType" TEXT NOT NULL,
"status" TEXT NOT NULL,
"payloadHash" TEXT NOT NULL,
"errorMessage" TEXT,
"receivedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"processedAt" TIMESTAMP(3),
CONSTRAINT "webhook_events_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX IF NOT EXISTS "webhook_events_provider_providerEventId_key"
ON "webhook_events"("provider", "providerEventId");
CREATE INDEX IF NOT EXISTS "webhook_events_provider_status_idx"
ON "webhook_events"("provider", "status");
@@ -0,0 +1,4 @@
-- Remove legacy plaintext company API key storage.
-- Company integrations must use company_api_keys, which stores only key hashes and prefixes.
DROP INDEX IF EXISTS "companies_apiKey_key";
ALTER TABLE "companies" DROP COLUMN IF EXISTS "apiKey";
@@ -0,0 +1,16 @@
-- CreateTable
CREATE TABLE "admin_recovery_codes" (
"id" TEXT NOT NULL,
"adminUserId" TEXT NOT NULL,
"codeHash" TEXT NOT NULL,
"usedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "admin_recovery_codes_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "admin_recovery_codes_adminUserId_usedAt_idx" ON "admin_recovery_codes"("adminUserId", "usedAt");
-- AddForeignKey
ALTER TABLE "admin_recovery_codes" ADD CONSTRAINT "admin_recovery_codes_adminUserId_fkey" FOREIGN KEY ("adminUserId") REFERENCES "admin_users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1 @@
provider = "postgresql"
File diff suppressed because it is too large Load Diff
+43
View File
@@ -0,0 +1,43 @@
import { PrismaClient } from '../generated'
import bcrypt from 'bcryptjs'
import { ensureDatabaseUrl } from '../src/runtime-config'
ensureDatabaseUrl()
const prisma = new PrismaClient()
async function main() {
const email = process.env.ADMIN_SEED_EMAIL ?? 'admin@rentaldrivego.com'
const password = process.env.ADMIN_SEED_PASSWORD ?? 'changeme123'
const firstName = process.env.ADMIN_SEED_FIRST_NAME ?? 'Super'
const lastName = process.env.ADMIN_SEED_LAST_NAME ?? 'Admin'
const existing = await prisma.adminUser.findUnique({ where: { email } })
if (existing) {
console.log(`Super admin already exists: ${email}`)
return
}
const passwordHash = await bcrypt.hash(password, 12)
const admin = await prisma.adminUser.create({
data: {
email,
firstName,
lastName,
passwordHash,
role: 'SUPER_ADMIN',
isActive: true,
},
})
console.log(`Created SUPER_ADMIN: ${admin.email} (id: ${admin.id})`)
console.log('Login with the password you set in ADMIN_SEED_PASSWORD')
}
main()
.catch((err) => {
console.error(err)
process.exit(1)
})
.finally(() => prisma.$disconnect())
+117
View File
@@ -0,0 +1,117 @@
#!/usr/bin/env node
const { spawnSync } = require('node:child_process')
const { readdirSync } = require('node:fs')
const path = require('node:path')
const {
ensureDatabaseUrl,
loadDefaultEnvFiles,
normalizeDatabaseUrlForExecution,
} = require('../src/runtime-config')
const { PrismaClient } = require('../generated')
const repoRoot = path.resolve(__dirname, '../../..')
const prismaBin = path.join(repoRoot, 'node_modules', '.bin', 'prisma')
const schemaPath = path.join(repoRoot, 'packages/database/prisma/schema.prisma')
const migrationsDir = path.join(repoRoot, 'packages/database/prisma/migrations')
function run(command, args) {
const result = spawnSync(command, args, {
stdio: 'inherit',
cwd: repoRoot,
env: process.env,
})
if (result.status !== 0) {
process.exit(result.status ?? 1)
}
}
async function main() {
loadDefaultEnvFiles(repoRoot)
ensureDatabaseUrl()
normalizeDatabaseUrlForExecution()
const prisma = new PrismaClient()
try {
const [state] = await prisma.$queryRawUnsafe(`
SELECT
to_regclass('public.companies') IS NOT NULL AS "hasCompanies",
to_regclass('public.employees') IS NOT NULL AS "hasEmployees",
to_regclass('public._prisma_migrations') IS NOT NULL AS "hasMigrationsTable"
`)
const failedMigrations = state.hasMigrationsTable
? await prisma.$queryRawUnsafe(`
SELECT migration_name
FROM "_prisma_migrations"
WHERE finished_at IS NULL
AND rolled_back_at IS NULL
ORDER BY started_at
`)
: []
const hasBaseSchema = state.hasCompanies || state.hasEmployees
if (!hasBaseSchema) {
if (failedMigrations.length > 0) {
console.log('[db:deploy] Fresh database with failed migration records detected; marking them rolled back before bootstrap')
for (const migration of failedMigrations) {
run(prismaBin, [
'migrate',
'resolve',
'--schema',
schemaPath,
'--rolled-back',
migration.migration_name,
])
}
}
console.log('[db:deploy] Fresh database detected; bootstrapping schema with prisma db push')
run(prismaBin, ['db', 'push', '--schema', schemaPath])
const migrationNames = readdirSync(migrationsDir, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
.sort()
if (migrationNames.length > 0) {
console.log('[db:deploy] Marking existing migrations as applied for the bootstrapped schema')
for (const migrationName of migrationNames) {
run(prismaBin, [
'migrate',
'resolve',
'--schema',
schemaPath,
'--applied',
migrationName,
])
}
}
return
}
if (failedMigrations.length > 0) {
console.error('[db:deploy] Found failed migrations in a non-empty database. Resolve them manually before deploying again.')
for (const migration of failedMigrations) {
console.error(`- ${migration.migration_name}`)
}
process.exit(1)
}
console.log('[db:deploy] Existing database detected; applying Prisma migrations')
run(prismaBin, ['migrate', 'deploy', '--schema', schemaPath])
} finally {
await prisma.$disconnect()
}
}
main().catch((error) => {
if (error?.message?.includes("Can't reach database server at `localhost:5432`")) {
console.error('[db:deploy] Hint: start the local Postgres service first. In this repo that is usually `docker compose -f docker-compose.dev.yml up -d postgres`.')
}
console.error('[db:deploy] Failed:', error)
process.exit(1)
})
+2
View File
@@ -0,0 +1,2 @@
export { PrismaClient } from '../generated'
export declare function ensureDatabaseUrl(env?: NodeJS.ProcessEnv): string | undefined
+9
View File
@@ -0,0 +1,9 @@
const { ensureDatabaseUrl } = require('./runtime-config')
const { PrismaClient } = require('../generated')
ensureDatabaseUrl()
module.exports = {
PrismaClient,
ensureDatabaseUrl,
}
+73
View File
@@ -0,0 +1,73 @@
export declare function ensureDatabaseUrl(env?: NodeJS.ProcessEnv): string | undefined
export type AdminRole = 'SUPER_ADMIN' | 'ADMIN' | 'SUPPORT' | 'FINANCE' | 'VIEWER'
export type EmployeeRole = 'OWNER' | 'MANAGER' | 'AGENT'
export type LicenseStatus = 'PENDING' | 'VALID' | 'EXPIRING' | 'APPROVED' | 'DENIED' | 'EXPIRED'
export type NotificationType =
| 'ACCOUNT_CREATED'
| 'NEW_BOOKING'
| 'BOOKING_CANCELLED'
| 'PAYMENT_RECEIVED'
| 'PAYMENT_FAILED'
| 'SUBSCRIPTION_TRIAL_ENDING'
| 'SUBSCRIPTION_SUSPENDED'
| 'VEHICLE_MAINTENANCE_DUE'
| 'OFFER_EXPIRING'
| 'NEW_REVIEW_RECEIVED'
| 'BOOKING_CONFIRMED'
| 'PICKUP_REMINDER_24H'
| 'PICKUP_REMINDER_2H'
| 'VEHICLE_READY'
| 'RETURN_REMINDER'
| 'BOOKING_CANCELLED_BY_COMPANY'
| 'REFUND_PROCESSED'
| 'NEW_OFFER_FROM_SAVED_COMPANY'
| 'REVIEW_REQUEST'
export type NotificationChannel = 'EMAIL' | 'SMS' | 'WHATSAPP' | 'IN_APP' | 'PUSH'
export interface Company {
id: string
name: string
slug: string
email: string
phone: string | null
status: string
}
export interface Employee {
id: string
companyId: string
clerkUserId: string
firstName: string
lastName: string
email: string
phone: string | null
role: EmployeeRole
isActive: boolean
company?: Company
}
export interface AdminUser {
id: string
email: string
firstName: string
lastName: string
role: AdminRole
isActive: boolean
totpEnabled?: boolean
totpSecret?: string | null
}
export interface InsurancePolicy {
id: string
companyId: string
name: string
description?: string | null
type: string
chargeType: 'PER_DAY' | 'PER_RENTAL' | 'PERCENTAGE_OF_RENTAL'
chargeValue: number
isRequired: boolean
isActive: boolean
sortOrder: number
}
+5
View File
@@ -0,0 +1,5 @@
const { ensureDatabaseUrl } = require('./runtime-config')
module.exports = {
ensureDatabaseUrl,
}
+73
View File
@@ -0,0 +1,73 @@
export { ensureDatabaseUrl } from './runtime-config'
export type AdminRole = 'SUPER_ADMIN' | 'ADMIN' | 'SUPPORT' | 'FINANCE' | 'VIEWER'
export type EmployeeRole = 'OWNER' | 'MANAGER' | 'AGENT'
export type LicenseStatus = 'PENDING' | 'VALID' | 'EXPIRING' | 'APPROVED' | 'DENIED' | 'EXPIRED'
export type NotificationType =
| 'ACCOUNT_CREATED'
| 'NEW_BOOKING'
| 'BOOKING_CANCELLED'
| 'PAYMENT_RECEIVED'
| 'PAYMENT_FAILED'
| 'SUBSCRIPTION_TRIAL_ENDING'
| 'SUBSCRIPTION_SUSPENDED'
| 'VEHICLE_MAINTENANCE_DUE'
| 'OFFER_EXPIRING'
| 'NEW_REVIEW_RECEIVED'
| 'BOOKING_CONFIRMED'
| 'PICKUP_REMINDER_24H'
| 'PICKUP_REMINDER_2H'
| 'VEHICLE_READY'
| 'RETURN_REMINDER'
| 'BOOKING_CANCELLED_BY_COMPANY'
| 'REFUND_PROCESSED'
| 'NEW_OFFER_FROM_SAVED_COMPANY'
| 'REVIEW_REQUEST'
export type NotificationChannel = 'EMAIL' | 'SMS' | 'WHATSAPP' | 'IN_APP' | 'PUSH'
export interface Company {
id: string
name: string
slug: string
email: string
phone: string | null
status: string
}
export interface Employee {
id: string
companyId: string
clerkUserId: string
firstName: string
lastName: string
email: string
phone: string | null
role: EmployeeRole
isActive: boolean
company?: Company
}
export interface AdminUser {
id: string
email: string
firstName: string
lastName: string
role: AdminRole
isActive: boolean
totpEnabled?: boolean
totpSecret?: string | null
}
export interface InsurancePolicy {
id: string
companyId: string
name: string
description?: string | null
type: string
chargeType: 'PER_DAY' | 'PER_RENTAL' | 'PERCENTAGE_OF_RENTAL'
chargeValue: number
isRequired: boolean
isActive: boolean
sortOrder: number
}
+123
View File
@@ -0,0 +1,123 @@
const fs = require('node:fs')
const path = require('node:path')
const ENABLED_VALUES = new Set(['1', 'true', 'yes'])
function parseEnvFile(content) {
const env = {}
for (const rawLine of content.split(/\r?\n/)) {
const line = rawLine.trim()
if (!line || line.startsWith('#')) continue
const normalized = line.startsWith('export ') ? line.slice(7).trim() : line
const separatorIndex = normalized.indexOf('=')
if (separatorIndex === -1) continue
const key = normalized.slice(0, separatorIndex).trim()
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue
let value = normalized.slice(separatorIndex + 1)
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
const quote = value[0]
value = value.slice(1, -1)
if (quote === '"') {
value = value
.replace(/\\n/g, '\n')
.replace(/\\r/g, '\r')
.replace(/\\t/g, '\t')
.replace(/\\"/g, '"')
.replace(/\\\\/g, '\\')
}
}
env[key] = value
}
return env
}
function loadEnvFile(filePath, env = process.env, { override = false } = {}) {
if (!fs.existsSync(filePath)) return false
const parsed = parseEnvFile(fs.readFileSync(filePath, 'utf8'))
for (const [key, value] of Object.entries(parsed)) {
if (override || env[key] === undefined) {
env[key] = value
}
}
return true
}
function isRunningInContainer() {
return fs.existsSync('/.dockerenv')
}
function normalizeDatabaseUrlForExecution(env = process.env) {
if (!env.DATABASE_URL) return env.DATABASE_URL
let parsed
try {
parsed = new URL(env.DATABASE_URL)
} catch {
return env.DATABASE_URL
}
if (parsed.hostname !== 'postgres' || isRunningInContainer()) {
return env.DATABASE_URL
}
const fallbackHost = env.POSTGRES_HOST_LOCAL ?? 'localhost'
parsed.hostname = fallbackHost
env.DATABASE_URL = parsed.toString()
return env.DATABASE_URL
}
function loadDefaultEnvFiles(baseDir, env = process.env) {
const candidates = [
path.join(baseDir, '.env'),
path.join(baseDir, '.env.local'),
path.join(baseDir, '.env.docker.dev'),
]
for (const candidate of candidates) {
loadEnvFile(candidate, env)
}
}
function ensureDatabaseUrl(env = process.env) {
const shouldBuildFromPostgres = ENABLED_VALUES.has(
env.DATABASE_URL_FROM_POSTGRES?.trim().toLowerCase() ?? '',
)
if (!shouldBuildFromPostgres) {
return env.DATABASE_URL
}
if (!env.POSTGRES_PASSWORD) {
throw new Error('DATABASE_URL_FROM_POSTGRES is enabled but POSTGRES_PASSWORD is not set')
}
const user = env.POSTGRES_USER ?? 'postgres'
const password = env.POSTGRES_PASSWORD
const host = env.POSTGRES_HOST ?? 'postgres'
const port = env.POSTGRES_PORT ?? '5432'
const database = env.POSTGRES_DB ?? 'rentaldrivego'
env.DATABASE_URL = `postgresql://${encodeURIComponent(user)}:${encodeURIComponent(password)}@${host}:${port}/${encodeURIComponent(database)}`
return env.DATABASE_URL
}
module.exports = {
ensureDatabaseUrl,
loadDefaultEnvFiles,
loadEnvFile,
normalizeDatabaseUrlForExecution,
}
+25
View File
@@ -0,0 +1,25 @@
const ENABLED_VALUES = new Set(['1', 'true', 'yes'])
export function ensureDatabaseUrl(env: NodeJS.ProcessEnv = process.env) {
const shouldBuildFromPostgres = ENABLED_VALUES.has(
env.DATABASE_URL_FROM_POSTGRES?.trim().toLowerCase() ?? '',
)
if (!shouldBuildFromPostgres) {
return env.DATABASE_URL
}
if (!env.POSTGRES_PASSWORD) {
throw new Error('DATABASE_URL_FROM_POSTGRES is enabled but POSTGRES_PASSWORD is not set')
}
const user = env.POSTGRES_USER ?? 'postgres'
const password = env.POSTGRES_PASSWORD
const host = env.POSTGRES_HOST ?? 'postgres'
const port = env.POSTGRES_PORT ?? '5432'
const database = env.POSTGRES_DB ?? 'rentaldrivego'
env.DATABASE_URL = `postgresql://${encodeURIComponent(user)}:${encodeURIComponent(password)}@${host}:${port}/${encodeURIComponent(database)}`
return env.DATABASE_URL
}
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": ".",
"outDir": "dist"
},
"include": ["prisma/**/*"]
}
+4
View File
@@ -0,0 +1,4 @@
> @rentaldrivego/types@1.0.0 build
> tsc
+22
View File
@@ -0,0 +1,22 @@
export interface ApiSuccess<T> {
data: T;
}
export interface ApiPaginated<T> {
data: T[];
total: number;
page: number;
pageSize: number;
totalPages: number;
}
export interface ApiError {
error: string;
message: string;
statusCode: number;
[key: string]: unknown;
}
export declare const PLAN_PRICES: Record<string, Record<string, Record<string, number>>>;
export declare const PLAN_FEATURES: Record<string, string[]>;
export type Locale = 'en' | 'fr' | 'ar';
export type SupportedCurrency = 'MAD';
export declare function getCurrencyLabel(locale?: Locale, _currency?: SupportedCurrency): string;
export declare function formatCurrency(amount: number, _currency?: SupportedCurrency, locale?: Locale): string;
+71
View File
@@ -0,0 +1,71 @@
"use strict";
// Standard API response shapes
Object.defineProperty(exports, "__esModule", { value: true });
exports.PLAN_FEATURES = exports.PLAN_PRICES = void 0;
exports.getCurrencyLabel = getCurrencyLabel;
exports.formatCurrency = formatCurrency;
// Plan prices in smallest currency unit (MAD, in centimes)
exports.PLAN_PRICES = {
STARTER: {
MONTHLY: { MAD: 2990 },
ANNUAL: { MAD: 29900 },
},
GROWTH: {
MONTHLY: { MAD: 3990 },
ANNUAL: { MAD: 39900 },
},
PRO: {
MONTHLY: { MAD: 99900 },
ANNUAL: { MAD: 999000 },
},
};
exports.PLAN_FEATURES = {
STARTER: [
'Up to 10 vehicles',
'1 user account',
'Basic analytics',
'Marketplace listing',
],
GROWTH: [
'Up to 50 vehicles',
'5 user accounts',
'Full analytics',
'Priority marketplace placement',
'Custom branding',
],
PRO: [
'Unlimited vehicles',
'Unlimited user accounts',
'Advanced reports',
'API access',
'Dedicated support',
],
};
function resolveLocale(locale) {
if (locale)
return locale;
const lang = typeof globalThis === 'object' && 'document' in globalThis
? globalThis.document?.documentElement?.lang
: undefined;
if (lang === 'en' || lang === 'fr' || lang === 'ar') {
return lang;
}
return 'en';
}
function getCurrencyLabel(locale, _currency = 'MAD') {
return resolveLocale(locale) === 'ar' ? 'درهم' : 'MAD';
}
function formatCurrency(amount, _currency = 'MAD', locale) {
const resolvedLocale = resolveLocale(locale);
const divisor = 100;
const value = amount / divisor;
const localeMap = { en: 'en-US', fr: 'fr-FR', ar: 'ar-MA' };
const formattedValue = new Intl.NumberFormat(localeMap[resolvedLocale], {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(value);
const currencyLabel = getCurrencyLabel(resolvedLocale, _currency);
return resolvedLocale === 'en'
? `${currencyLabel} ${formattedValue}`
: `${formattedValue} ${currencyLabel}`;
}
+13
View File
@@ -0,0 +1,13 @@
export interface DamageZone {
zone: VehicleZone;
severity: 'SCRATCH' | 'DENT' | 'CRACK' | 'MISSING' | 'OTHER';
note?: string;
}
export type VehicleZone = 'hood' | 'roof' | 'trunk' | 'front-left-door' | 'front-right-door' | 'rear-left-door' | 'rear-right-door' | 'front-left-fender' | 'front-right-fender' | 'rear-left-fender' | 'rear-right-fender' | 'front-bumper' | 'rear-bumper' | 'windshield' | 'rear-window' | 'left-mirror' | 'right-mirror' | 'front-left-wheel' | 'front-right-wheel' | 'rear-left-wheel' | 'rear-right-wheel' | 'interior' | 'under-hood' | 'undercarriage';
export declare const DAMAGE_ZONE_COORDS: Record<VehicleZone, {
x: number;
y: number;
w: number;
h: number;
}>;
export declare const SEVERITY_COLORS: Record<DamageZone['severity'], string>;
+36
View File
@@ -0,0 +1,36 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.SEVERITY_COLORS = exports.DAMAGE_ZONE_COORDS = void 0;
exports.DAMAGE_ZONE_COORDS = {
'hood': { x: 48, y: 8, w: 64, h: 28 },
'roof': { x: 48, y: 52, w: 64, h: 56 },
'trunk': { x: 48, y: 124, w: 64, h: 28 },
'front-bumper': { x: 48, y: 0, w: 64, h: 10 },
'rear-bumper': { x: 48, y: 150, w: 64, h: 10 },
'front-left-fender': { x: 20, y: 10, w: 30, h: 35 },
'front-right-fender': { x: 110, y: 10, w: 30, h: 35 },
'front-left-door': { x: 20, y: 52, w: 30, h: 30 },
'front-right-door': { x: 110, y: 52, w: 30, h: 30 },
'rear-left-door': { x: 20, y: 82, w: 30, h: 30 },
'rear-right-door': { x: 110, y: 82, w: 30, h: 30 },
'rear-left-fender': { x: 20, y: 112, w: 30, h: 35 },
'rear-right-fender': { x: 110, y: 112, w: 30, h: 35 },
'windshield': { x: 52, y: 38, w: 56, h: 18 },
'rear-window': { x: 52, y: 104, w: 56, h: 18 },
'left-mirror': { x: 8, y: 50, w: 14, h: 10 },
'right-mirror': { x: 138, y: 50, w: 14, h: 10 },
'front-left-wheel': { x: 10, y: 24, w: 22, h: 26 },
'front-right-wheel': { x: 128, y: 24, w: 22, h: 26 },
'rear-left-wheel': { x: 10, y: 110, w: 22, h: 26 },
'rear-right-wheel': { x: 128, y: 110, w: 22, h: 26 },
'interior': { x: 55, y: 58, w: 50, h: 44 },
'under-hood': { x: 52, y: 10, w: 56, h: 20 },
'undercarriage': { x: 52, y: 130, w: 56, h: 20 },
};
exports.SEVERITY_COLORS = {
SCRATCH: '#F59E0B',
DENT: '#EF4444',
CRACK: '#8B5CF6',
MISSING: '#374151',
OTHER: '#6B7280',
};
+2
View File
@@ -0,0 +1,2 @@
export type FuelPolicyType = 'FULL_TO_FULL' | 'FULL_TO_EMPTY' | 'SAME_TO_SAME' | 'PREPAID' | 'FREE';
export declare const FUEL_POLICY_LABELS: Record<FuelPolicyType, Record<'en' | 'fr' | 'ar', string>>;
+30
View File
@@ -0,0 +1,30 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.FUEL_POLICY_LABELS = void 0;
exports.FUEL_POLICY_LABELS = {
FULL_TO_FULL: {
en: 'Full-to-Full: Return vehicle with a full tank. A refueling fee applies if returned with less fuel.',
fr: "Plein à plein : retournez le véhicule avec le plein. Des frais de ravitaillement s'appliquent sinon.",
ar: 'ممتلئ إلى ممتلئ: يجب إعادة السيارة بخزان ممتلئ. رسوم إضافية في حال الإعادة بمستوى أقل.',
},
FULL_TO_EMPTY: {
en: 'Full-to-Empty: Vehicle is provided with a full tank. No refund for unused fuel upon return.',
fr: "Plein à vide : le véhicule est livré avec le plein. Aucun remboursement n'est accordé pour le carburant non utilisé.",
ar: 'ممتلئ إلى فارغ: تُسلَّم السيارة بخزان ممتلئ. لا استرداد للوقود غير المستخدم.',
},
SAME_TO_SAME: {
en: 'Same-to-Same: Return vehicle with the same fuel level as at pickup.',
fr: "Même niveau : retournez le véhicule avec le même niveau de carburant qu'au départ.",
ar: 'نفس المستوى: أعد السيارة بنفس مستوى الوقود عند الاستلام.',
},
PREPAID: {
en: 'Prepaid Fuel: You pre-purchase a full tank at a fixed rate. Return at any fuel level.',
fr: "Carburant prépayé : vous achetez un plein à tarif fixe à l'avance. Retour possible avec n'importe quel niveau.",
ar: 'الوقود المدفوع مسبقاً: تدفع مقدماً ثمن خزان كامل بسعر ثابت. يمكن إعادة السيارة بأي مستوى.',
},
FREE: {
en: 'Fuel Included: Fuel cost is included in the rental price.',
fr: 'Carburant inclus : le coût du carburant est inclus dans le tarif de location.',
ar: 'الوقود مشمول: تكلفة الوقود مشمولة في سعر الإيجار.',
},
};
+4
View File
@@ -0,0 +1,4 @@
export * from './damage';
export * from './fuel';
export * from './api';
export * from './marketplace-homepage';
+20
View File
@@ -0,0 +1,20 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __exportStar = (this && this.__exportStar) || function(m, exports) {
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
};
Object.defineProperty(exports, "__esModule", { value: true });
__exportStar(require("./damage"), exports);
__exportStar(require("./fuel"), exports);
__exportStar(require("./api"), exports);
__exportStar(require("./marketplace-homepage"), exports);
+53
View File
@@ -0,0 +1,53 @@
export type MarketplaceLanguage = 'en' | 'fr' | 'ar';
export type MarketplaceHomepageMetric = {
value: string;
label: string;
};
export type MarketplaceHomepagePillar = {
title: string;
body: string;
};
export type MarketplaceHomepageStep = {
step: string;
title: string;
body: string;
};
export type MarketplaceHomepageSectionType = 'hero' | 'surface' | 'pillars' | 'audiences' | 'features' | 'steps' | 'closing';
export type MarketplaceHomepageContent = {
sections: MarketplaceHomepageSectionType[];
heroKicker: string;
heroTitle: string;
heroBody: string;
startTrial: string;
exploreVehicles: string;
surfaceLabel: string;
surfaceTitle: string;
surfaceBody: string;
liveLabel: string;
trustedFleets: string;
brandedFlows: string;
multiTenant: string;
companyKicker: string;
companyTitle: string;
companyBody: string;
renterKicker: string;
renterTitle: string;
renterBody: string;
pillars: MarketplaceHomepagePillar[];
metrics: MarketplaceHomepageMetric[];
featureLabel: string;
features: string[];
stepsTitle: string;
steps: MarketplaceHomepageStep[];
stepLabel: string;
readyKicker: string;
readyTitle: string;
readyBody: string;
viewPricing: string;
createWorkspace: string;
};
export type MarketplaceHomepageConfig = Record<MarketplaceLanguage, MarketplaceHomepageContent>;
export declare const defaultMarketplaceHomepageSections: MarketplaceHomepageSectionType[];
export declare const defaultMarketplaceHomepageContent: MarketplaceHomepageConfig;
export declare function cloneMarketplaceHomepageContent(): MarketplaceHomepageConfig;
export declare function resolveMarketplaceHomepageSections(sections?: MarketplaceHomepageSectionType[] | null): MarketplaceHomepageSectionType[];
+173
View File
@@ -0,0 +1,173 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.defaultMarketplaceHomepageContent = exports.defaultMarketplaceHomepageSections = void 0;
exports.cloneMarketplaceHomepageContent = cloneMarketplaceHomepageContent;
exports.resolveMarketplaceHomepageSections = resolveMarketplaceHomepageSections;
exports.defaultMarketplaceHomepageSections = [
'hero',
'surface',
'pillars',
'audiences',
'features',
'steps',
'closing',
];
exports.defaultMarketplaceHomepageContent = {
en: {
sections: exports.defaultMarketplaceHomepageSections,
heroKicker: 'RentalDriveGo',
heroTitle: 'Marketplace discovery with a sharper front door.',
heroBody: 'Rental companies run private operations, renters browse a shared marketplace, and every booking ends on the companys own branded checkout.',
startTrial: 'Start free trial',
exploreVehicles: 'Explore vehicles',
surfaceLabel: 'Marketplace surface',
surfaceTitle: 'Designed for two audiences at once.',
surfaceBody: 'Operators need control, renters need confidence. The marketplace should show both without feeling like a template.',
liveLabel: 'Live network',
trustedFleets: 'Trusted fleets',
brandedFlows: 'Branded booking flows',
multiTenant: 'Multi-tenant operations',
companyKicker: 'Operator workflow',
companyTitle: 'Manage inventory, offers, billing, and staff from one control layer.',
companyBody: 'Publish inventory once, decide what appears publicly, and keep contracts, payments, and reporting isolated per company.',
renterKicker: 'Renter experience',
renterTitle: 'Let renters compare quickly, then hand them off to the right company site.',
renterBody: 'The marketplace works as a discovery engine, not a dead-end aggregator. Search here, reserve there, and pay direct.',
pillars: [
['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.'],
['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: title, body: body })),
metrics: [
['01', 'Shared marketplace visibility'],
['02', 'Direct company checkout'],
['03', 'Company-owned payments'],
].map(([value, label]) => ({ value: value, label: label })),
featureLabel: 'What the product covers',
features: [
'Fleet management with multi-photo uploads',
'Marketplace offers and redirect booking flow',
'Branded public booking site per company',
'Customer CRM, analytics, and billing controls',
],
stepsTitle: 'How companies launch',
steps: [
['1', 'Create your company workspace', 'Pick a plan, launch your 90-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.'],
['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: step, title: title, body: body })),
stepLabel: 'Step',
readyKicker: 'Ready to launch',
readyTitle: 'The marketplace homepage should explain the product in seconds.',
readyBody: 'Use the marketplace to attract demand, then move renters into a branded experience that keeps pricing, payments, and trust tied to your company.',
viewPricing: 'View pricing',
createWorkspace: 'Create workspace',
},
fr: {
sections: exports.defaultMarketplaceHomepageSections,
heroKicker: 'RentalDriveGo',
heroTitle: 'Une vitrine marketplace plus claire et plus percutante.',
heroBody: "Les entreprises de location gèrent leurs opérations en privé, les clients parcourent une marketplace commune et chaque réservation se finalise sur le parcours de paiement aux couleurs de l'entreprise.",
startTrial: 'Commencer lessai gratuit',
exploreVehicles: 'Explorer les véhicules',
surfaceLabel: 'Vitrine marketplace',
surfaceTitle: 'Pensée pour deux audiences à la fois.',
surfaceBody: 'Les opérateurs veulent du contrôle, les clients veulent de la confiance. La marketplace doit montrer les deux sans ressembler à un modèle générique.',
liveLabel: 'Réseau actif',
trustedFleets: 'Flottes fiables',
brandedFlows: 'Parcours de marque',
multiTenant: 'Opérations multi-tenant',
companyKicker: 'Flux opérateur',
companyTitle: 'Pilotez linventaire, les offres, la facturation et l’équipe depuis un seul centre de contrôle.',
companyBody: 'Publiez une seule fois, choisissez ce qui apparaît publiquement, et gardez contrats, paiements et rapports isolés par entreprise.',
renterKicker: 'Expérience client',
renterTitle: 'Laissez les clients comparer rapidement, puis redirigez-les vers le bon site dentreprise.',
renterBody: 'La marketplace sert de moteur de découverte, pas dagrégateur sans suite. Recherche ici, réservation là-bas, paiement direct.',
pillars: [
['Publication unifiée', 'Les photos, tarifs et offres circulent depuis un seul flux dadministration 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.'],
['Revenus en direct', 'Les réservations basculent vers le paiement propre à lentreprise afin de préserver la relation client et lencaissement.'],
].map(([title, body]) => ({ title: title, body: body })),
metrics: [
['01', 'Visibilité marketplace partagée'],
['02', 'Paiement direct entreprise'],
['03', 'Paiements gérés par lentreprise'],
].map(([value, label]) => ({ value: value, label: label })),
featureLabel: 'Ce que couvre le produit',
features: [
'Gestion de flotte avec téléversement de plusieurs photos',
'Offres marketplace et redirection vers la réservation',
'Site public de réservation par entreprise',
'CRM client, analytics et contrôle de facturation',
],
stepsTitle: 'Comment les entreprises démarrent',
steps: [
['1', 'Créez votre espace entreprise', 'Choisissez un plan, lancez votre essai de 90 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.'],
['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: step, title: title, body: body })),
stepLabel: 'Étape',
readyKicker: 'Prêt à démarrer',
readyTitle: 'La page daccueil marketplace doit présenter le produit en quelques secondes.',
readyBody: 'Utilisez la marketplace pour capter la demande, puis orientez les clients vers une expérience de marque où les prix, les paiements et la confiance restent attachés à votre entreprise.',
viewPricing: 'Voir les tarifs',
createWorkspace: 'Créer lespace',
},
ar: {
sections: exports.defaultMarketplaceHomepageSections,
heroKicker: 'RentalDriveGo',
heroTitle: 'واجهة سوق أكثر وضوحاً وتأثيراً.',
heroBody: 'شركات التأجير تدير عملياتها بشكل خاص، والمستأجرون يتصفحون سوقاً مشتركاً، وكل حجز ينتهي في صفحة دفع تحمل هوية الشركة ذاتها.',
startTrial: 'ابدأ التجربة المجانية',
exploreVehicles: 'استكشف السيارات',
surfaceLabel: 'واجهة السوق',
surfaceTitle: 'مصممة لجمهورين في الوقت نفسه.',
surfaceBody: 'المشغلون يريدون التحكم، والمستأجرون يريدون الثقة. يجب أن تُظهر المنصة الأمرين معاً من دون أن تبدو كقالب عادي.',
liveLabel: 'شبكة نشطة',
trustedFleets: 'أساطيل موثوقة',
brandedFlows: 'مسارات حجز مخصصة',
multiTenant: 'عمليات متعددة الشركات',
companyKicker: 'سير عمل المشغل',
companyTitle: 'تحكم في المخزون والعروض والفوترة والفريق من طبقة تشغيل واحدة.',
companyBody: 'انشر المخزون مرة واحدة، وحدد ما يظهر للعامة، واحتفظ بالعقود والمدفوعات والتقارير معزولة لكل شركة.',
renterKicker: 'تجربة المستأجر',
renterTitle: 'دع المستأجرين يقارنون بسرعة ثم وجّههم إلى موقع الشركة المناسب.',
renterBody: 'هذه المنصة محرك لاكتشاف الخيارات، وليست مُجمِّعاً بلا مسار واضح. ابحث هنا، احجز هناك، وادفع مباشرة للشركة.',
pillars: [
['نشر موحد', 'صور السيارات والأسعار والعروض تنتقل من سير إدارة واحد إلى السوق وصفحات الحجز ذات الهوية الخاصة.'],
['اكتشاف مؤهل', 'تساعد العروض المميزة، والتصفح حسب الموقع، وإشارات السمعة المستأجرين على تحديد خياراتهم بسرعة.'],
['مسار إيراد مباشر', 'تنتقل الحجوزات إلى صفحة الدفع الخاصة بالشركة حتى تبقى علاقة العميل والتحصيل المالي بيد الشركة نفسها.'],
].map(([title, body]) => ({ title: title, body: body })),
metrics: [
['01', 'ظهور مشترك في السوق'],
['02', 'دفع مباشر للشركة'],
['03', 'مدفوعات مملوكة للشركة'],
].map(([value, label]) => ({ value: value, label: label })),
featureLabel: 'ما الذي يقدمه المنتج',
features: [
'إدارة الأسطول مع رفع عدة صور',
'عروض السوق والتحويل إلى مسار الحجز',
'موقع حجز عام مخصص لكل شركة',
'إدارة العملاء والتحليلات والفوترة',
],
stepsTitle: 'كيف تبدأ الشركات',
steps: [
['1', 'أنشئ مساحة عمل شركتك', 'اختر الخطة وابدأ تجربتك لمدة 14 يوماً ثم فعّل حساب المالك.'],
['2', 'انشر السيارات والعروض', 'ارفع الصور مرة واحدة وتحكم فيما يظهر في السوق وفي الموقع المخصص.'],
['3', 'استقبل الحجوزات على موقعك', 'يكتشف المستأجرون أسطولك على RentalDriveGo ثم يدفعون مباشرة عبر مسار الحجز الخاص بك.'],
].map(([step, title, body]) => ({ step: step, title: title, body: body })),
stepLabel: 'الخطوة',
readyKicker: 'جاهز للانطلاق',
readyTitle: 'يجب أن تشرح الصفحة الرئيسية قيمة المنصة خلال ثوانٍ.',
readyBody: 'استخدم السوق لجذب الطلب، ثم انقل المستأجرين إلى تجربة تحمل هوية شركتك، بحيث تبقى الأسعار والمدفوعات والثقة مرتبطة بنشاطك.',
viewPricing: 'عرض الأسعار',
createWorkspace: 'إنشاء المساحة',
},
};
function cloneMarketplaceHomepageContent() {
return JSON.parse(JSON.stringify(exports.defaultMarketplaceHomepageContent));
}
function resolveMarketplaceHomepageSections(sections) {
const source = sections?.length ? sections : exports.defaultMarketplaceHomepageSections;
return source.filter((section, index) => source.indexOf(section) === index);
}
+14
View File
@@ -0,0 +1,14 @@
{
"name": "@rentaldrivego/types",
"version": "1.0.0",
"private": true,
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"scripts": {
"build": "tsc",
"type-check": "tsc --noEmit"
},
"devDependencies": {
"typescript": "^5.4.0"
}
}
+97
View File
@@ -0,0 +1,97 @@
// Standard API response shapes
export interface ApiSuccess<T> {
data: T
}
export interface ApiPaginated<T> {
data: T[]
total: number
page: number
pageSize: number
totalPages: number
}
export interface ApiError {
error: string
message: string
statusCode: number
[key: string]: unknown
}
// Plan prices in smallest currency unit (MAD, in centimes)
export const PLAN_PRICES: Record<string, Record<string, Record<string, number>>> = {
STARTER: {
MONTHLY: { MAD: 2990 },
ANNUAL: { MAD: 29900 },
},
GROWTH: {
MONTHLY: { MAD: 3990 },
ANNUAL: { MAD: 39900 },
},
PRO: {
MONTHLY: { MAD: 99900 },
ANNUAL: { MAD: 999000 },
},
}
export const PLAN_FEATURES: Record<string, string[]> = {
STARTER: [
'Up to 10 vehicles',
'1 user account',
'Basic analytics',
'Marketplace listing',
],
GROWTH: [
'Up to 50 vehicles',
'5 user accounts',
'Full analytics',
'Priority marketplace placement',
'Custom branding',
],
PRO: [
'Unlimited vehicles',
'Unlimited user accounts',
'Advanced reports',
'API access',
'Dedicated support',
],
}
export type Locale = 'en' | 'fr' | 'ar'
export type SupportedCurrency = 'MAD'
function resolveLocale(locale?: Locale): Locale {
if (locale) return locale
const lang =
typeof globalThis === 'object' && 'document' in globalThis
? (globalThis as { document?: { documentElement?: { lang?: string } } }).document?.documentElement?.lang
: undefined
if (lang === 'en' || lang === 'fr' || lang === 'ar') {
return lang
}
return 'en'
}
export function getCurrencyLabel(locale?: Locale, _currency: SupportedCurrency = 'MAD'): string {
return resolveLocale(locale) === 'ar' ? 'درهم' : 'MAD'
}
export function formatCurrency(amount: number, _currency: SupportedCurrency = 'MAD', locale?: Locale): string {
const resolvedLocale = resolveLocale(locale)
const divisor = 100
const value = amount / divisor
const localeMap: Record<Locale, string> = { en: 'en-US', fr: 'fr-FR', ar: 'ar-MA' }
const formattedValue = new Intl.NumberFormat(localeMap[resolvedLocale], {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(value)
const currencyLabel = getCurrencyLabel(resolvedLocale, _currency)
return resolvedLocale === 'en'
? `${currencyLabel} ${formattedValue}`
: `${formattedValue} ${currencyLabel}`
}
+53
View File
@@ -0,0 +1,53 @@
export interface DamageZone {
zone: VehicleZone
severity: 'SCRATCH' | 'DENT' | 'CRACK' | 'MISSING' | 'OTHER'
note?: string
}
export type VehicleZone =
| 'hood' | 'roof' | 'trunk'
| 'front-left-door' | 'front-right-door'
| 'rear-left-door' | 'rear-right-door'
| 'front-left-fender' | 'front-right-fender'
| 'rear-left-fender' | 'rear-right-fender'
| 'front-bumper' | 'rear-bumper'
| 'windshield' | 'rear-window'
| 'left-mirror' | 'right-mirror'
| 'front-left-wheel' | 'front-right-wheel'
| 'rear-left-wheel' | 'rear-right-wheel'
| 'interior' | 'under-hood' | 'undercarriage'
export const DAMAGE_ZONE_COORDS: Record<VehicleZone, { x: number; y: number; w: number; h: number }> = {
'hood': { x: 48, y: 8, w: 64, h: 28 },
'roof': { x: 48, y: 52, w: 64, h: 56 },
'trunk': { x: 48, y: 124, w: 64, h: 28 },
'front-bumper': { x: 48, y: 0, w: 64, h: 10 },
'rear-bumper': { x: 48, y: 150, w: 64, h: 10 },
'front-left-fender': { x: 20, y: 10, w: 30, h: 35 },
'front-right-fender': { x: 110, y: 10, w: 30, h: 35 },
'front-left-door': { x: 20, y: 52, w: 30, h: 30 },
'front-right-door': { x: 110, y: 52, w: 30, h: 30 },
'rear-left-door': { x: 20, y: 82, w: 30, h: 30 },
'rear-right-door': { x: 110, y: 82, w: 30, h: 30 },
'rear-left-fender': { x: 20, y: 112, w: 30, h: 35 },
'rear-right-fender': { x: 110, y: 112, w: 30, h: 35 },
'windshield': { x: 52, y: 38, w: 56, h: 18 },
'rear-window': { x: 52, y: 104, w: 56, h: 18 },
'left-mirror': { x: 8, y: 50, w: 14, h: 10 },
'right-mirror': { x: 138, y: 50, w: 14, h: 10 },
'front-left-wheel': { x: 10, y: 24, w: 22, h: 26 },
'front-right-wheel': { x: 128, y: 24, w: 22, h: 26 },
'rear-left-wheel': { x: 10, y: 110, w: 22, h: 26 },
'rear-right-wheel': { x: 128, y: 110, w: 22, h: 26 },
'interior': { x: 55, y: 58, w: 50, h: 44 },
'under-hood': { x: 52, y: 10, w: 56, h: 20 },
'undercarriage': { x: 52, y: 130, w: 56, h: 20 },
}
export const SEVERITY_COLORS: Record<DamageZone['severity'], string> = {
SCRATCH: '#F59E0B',
DENT: '#EF4444',
CRACK: '#8B5CF6',
MISSING: '#374151',
OTHER: '#6B7280',
}
+29
View File
@@ -0,0 +1,29 @@
export type FuelPolicyType = 'FULL_TO_FULL' | 'FULL_TO_EMPTY' | 'SAME_TO_SAME' | 'PREPAID' | 'FREE'
export const FUEL_POLICY_LABELS: Record<FuelPolicyType, Record<'en' | 'fr' | 'ar', string>> = {
FULL_TO_FULL: {
en: 'Full-to-Full: Return vehicle with a full tank. A refueling fee applies if returned with less fuel.',
fr: "Plein à plein : retournez le véhicule avec le plein. Des frais de ravitaillement s'appliquent sinon.",
ar: 'ممتلئ إلى ممتلئ: يجب إعادة السيارة بخزان ممتلئ. رسوم إضافية في حال الإعادة بمستوى أقل.',
},
FULL_TO_EMPTY: {
en: 'Full-to-Empty: Vehicle is provided with a full tank. No refund for unused fuel upon return.',
fr: "Plein à vide : le véhicule est livré avec le plein. Aucun remboursement n'est accordé pour le carburant non utilisé.",
ar: 'ممتلئ إلى فارغ: تُسلَّم السيارة بخزان ممتلئ. لا استرداد للوقود غير المستخدم.',
},
SAME_TO_SAME: {
en: 'Same-to-Same: Return vehicle with the same fuel level as at pickup.',
fr: "Même niveau : retournez le véhicule avec le même niveau de carburant qu'au départ.",
ar: 'نفس المستوى: أعد السيارة بنفس مستوى الوقود عند الاستلام.',
},
PREPAID: {
en: 'Prepaid Fuel: You pre-purchase a full tank at a fixed rate. Return at any fuel level.',
fr: "Carburant prépayé : vous achetez un plein à tarif fixe à l'avance. Retour possible avec n'importe quel niveau.",
ar: 'الوقود المدفوع مسبقاً: تدفع مقدماً ثمن خزان كامل بسعر ثابت. يمكن إعادة السيارة بأي مستوى.',
},
FREE: {
en: 'Fuel Included: Fuel cost is included in the rental price.',
fr: 'Carburant inclus : le coût du carburant est inclus dans le tarif de location.',
ar: 'الوقود مشمول: تكلفة الوقود مشمولة في سعر الإيجار.',
},
}
+4
View File
@@ -0,0 +1,4 @@
export * from './damage'
export * from './fuel'
export * from './api'
export * from './marketplace-homepage'
+251
View File
@@ -0,0 +1,251 @@
export type MarketplaceLanguage = 'en' | 'fr' | 'ar'
export type MarketplaceHomepageMetric = {
value: string
label: string
}
export type MarketplaceHomepagePillar = {
title: string
body: string
}
export type MarketplaceHomepageStep = {
step: string
title: string
body: string
}
export type MarketplaceHomepageSectionType =
| 'hero'
| 'surface'
| 'pillars'
| 'audiences'
| 'features'
| 'steps'
| 'closing'
export type MarketplaceHomepageContent = {
sections: MarketplaceHomepageSectionType[]
heroKicker: string
heroTitle: string
heroBody: string
startTrial: string
exploreVehicles: string
surfaceLabel: string
surfaceTitle: string
surfaceBody: string
liveLabel: string
trustedFleets: string
brandedFlows: string
multiTenant: string
companyKicker: string
companyTitle: string
companyBody: string
renterKicker: string
renterTitle: string
renterBody: string
pillars: MarketplaceHomepagePillar[]
metrics: MarketplaceHomepageMetric[]
featureLabel: string
features: string[]
stepsTitle: string
steps: MarketplaceHomepageStep[]
stepLabel: string
readyKicker: string
readyTitle: string
readyBody: string
viewPricing: string
createWorkspace: string
}
export type MarketplaceHomepageConfig = Record<MarketplaceLanguage, MarketplaceHomepageContent>
export const defaultMarketplaceHomepageSections: MarketplaceHomepageSectionType[] = [
'hero',
'surface',
'pillars',
'audiences',
'features',
'steps',
'closing',
]
export const defaultMarketplaceHomepageContent: MarketplaceHomepageConfig = {
en: {
sections: defaultMarketplaceHomepageSections,
heroKicker: 'RentalDriveGo',
heroTitle: 'Marketplace discovery with a sharper front door.',
heroBody:
'Rental companies run private operations, renters browse a shared marketplace, and every booking ends on the companys own branded checkout.',
startTrial: 'Start free trial',
exploreVehicles: 'Explore vehicles',
surfaceLabel: 'Marketplace surface',
surfaceTitle: 'Designed for two audiences at once.',
surfaceBody:
'Operators need control, renters need confidence. The marketplace should show both without feeling like a template.',
liveLabel: 'Live network',
trustedFleets: 'Trusted fleets',
brandedFlows: 'Branded booking flows',
multiTenant: 'Multi-tenant operations',
companyKicker: 'Operator workflow',
companyTitle: 'Manage inventory, offers, billing, and staff from one control layer.',
companyBody:
'Publish inventory once, decide what appears publicly, and keep contracts, payments, and reporting isolated per company.',
renterKicker: 'Renter experience',
renterTitle: 'Let renters compare quickly, then hand them off to the right company site.',
renterBody:
'The marketplace works as a discovery engine, not a dead-end aggregator. Search here, reserve there, and pay direct.',
pillars: [
['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.'],
['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: title!, body: body! })),
metrics: [
['01', 'Shared marketplace visibility'],
['02', 'Direct company checkout'],
['03', 'Company-owned payments'],
].map(([value, label]) => ({ value: value!, label: label! })),
featureLabel: 'What the product covers',
features: [
'Fleet management with multi-photo uploads',
'Marketplace offers and redirect booking flow',
'Branded public booking site per company',
'Customer CRM, analytics, and billing controls',
],
stepsTitle: 'How companies launch',
steps: [
['1', 'Create your company workspace', 'Pick a plan, launch your 90-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.'],
['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: step!, title: title!, body: body! })),
stepLabel: 'Step',
readyKicker: 'Ready to launch',
readyTitle: 'The marketplace homepage should explain the product in seconds.',
readyBody:
'Use the marketplace to attract demand, then move renters into a branded experience that keeps pricing, payments, and trust tied to your company.',
viewPricing: 'View pricing',
createWorkspace: 'Create workspace',
},
fr: {
sections: defaultMarketplaceHomepageSections,
heroKicker: 'RentalDriveGo',
heroTitle: 'Une vitrine marketplace plus claire et plus percutante.',
heroBody:
"Les entreprises de location gèrent leurs opérations en privé, les clients parcourent une marketplace commune et chaque réservation se finalise sur le parcours de paiement aux couleurs de l'entreprise.",
startTrial: 'Commencer lessai gratuit',
exploreVehicles: 'Explorer les véhicules',
surfaceLabel: 'Vitrine marketplace',
surfaceTitle: 'Pensée pour deux audiences à la fois.',
surfaceBody:
'Les opérateurs veulent du contrôle, les clients veulent de la confiance. La marketplace doit montrer les deux sans ressembler à un modèle générique.',
liveLabel: 'Réseau actif',
trustedFleets: 'Flottes fiables',
brandedFlows: 'Parcours de marque',
multiTenant: 'Opérations multi-tenant',
companyKicker: 'Flux opérateur',
companyTitle: 'Pilotez linventaire, les offres, la facturation et l’équipe depuis un seul centre de contrôle.',
companyBody:
'Publiez une seule fois, choisissez ce qui apparaît publiquement, et gardez contrats, paiements et rapports isolés par entreprise.',
renterKicker: 'Expérience client',
renterTitle: 'Laissez les clients comparer rapidement, puis redirigez-les vers le bon site dentreprise.',
renterBody:
'La marketplace sert de moteur de découverte, pas dagrégateur sans suite. Recherche ici, réservation là-bas, paiement direct.',
pillars: [
['Publication unifiée', 'Les photos, tarifs et offres circulent depuis un seul flux dadministration 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.'],
['Revenus en direct', 'Les réservations basculent vers le paiement propre à lentreprise afin de préserver la relation client et lencaissement.'],
].map(([title, body]) => ({ title: title!, body: body! })),
metrics: [
['01', 'Visibilité marketplace partagée'],
['02', 'Paiement direct entreprise'],
['03', 'Paiements gérés par lentreprise'],
].map(([value, label]) => ({ value: value!, label: label! })),
featureLabel: 'Ce que couvre le produit',
features: [
'Gestion de flotte avec téléversement de plusieurs photos',
'Offres marketplace et redirection vers la réservation',
'Site public de réservation par entreprise',
'CRM client, analytics et contrôle de facturation',
],
stepsTitle: 'Comment les entreprises démarrent',
steps: [
['1', 'Créez votre espace entreprise', 'Choisissez un plan, lancez votre essai de 90 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.'],
['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: step!, title: title!, body: body! })),
stepLabel: 'Étape',
readyKicker: 'Prêt à démarrer',
readyTitle: 'La page daccueil marketplace doit présenter le produit en quelques secondes.',
readyBody:
'Utilisez la marketplace pour capter la demande, puis orientez les clients vers une expérience de marque où les prix, les paiements et la confiance restent attachés à votre entreprise.',
viewPricing: 'Voir les tarifs',
createWorkspace: 'Créer lespace',
},
ar: {
sections: defaultMarketplaceHomepageSections,
heroKicker: 'RentalDriveGo',
heroTitle: 'واجهة سوق أكثر وضوحاً وتأثيراً.',
heroBody:
'شركات التأجير تدير عملياتها بشكل خاص، والمستأجرون يتصفحون سوقاً مشتركاً، وكل حجز ينتهي في صفحة دفع تحمل هوية الشركة ذاتها.',
startTrial: 'ابدأ التجربة المجانية',
exploreVehicles: 'استكشف السيارات',
surfaceLabel: 'واجهة السوق',
surfaceTitle: 'مصممة لجمهورين في الوقت نفسه.',
surfaceBody:
'المشغلون يريدون التحكم، والمستأجرون يريدون الثقة. يجب أن تُظهر المنصة الأمرين معاً من دون أن تبدو كقالب عادي.',
liveLabel: 'شبكة نشطة',
trustedFleets: 'أساطيل موثوقة',
brandedFlows: 'مسارات حجز مخصصة',
multiTenant: 'عمليات متعددة الشركات',
companyKicker: 'سير عمل المشغل',
companyTitle: 'تحكم في المخزون والعروض والفوترة والفريق من طبقة تشغيل واحدة.',
companyBody:
'انشر المخزون مرة واحدة، وحدد ما يظهر للعامة، واحتفظ بالعقود والمدفوعات والتقارير معزولة لكل شركة.',
renterKicker: 'تجربة المستأجر',
renterTitle: 'دع المستأجرين يقارنون بسرعة ثم وجّههم إلى موقع الشركة المناسب.',
renterBody:
'هذه المنصة محرك لاكتشاف الخيارات، وليست مُجمِّعاً بلا مسار واضح. ابحث هنا، احجز هناك، وادفع مباشرة للشركة.',
pillars: [
['نشر موحد', 'صور السيارات والأسعار والعروض تنتقل من سير إدارة واحد إلى السوق وصفحات الحجز ذات الهوية الخاصة.'],
['اكتشاف مؤهل', 'تساعد العروض المميزة، والتصفح حسب الموقع، وإشارات السمعة المستأجرين على تحديد خياراتهم بسرعة.'],
['مسار إيراد مباشر', 'تنتقل الحجوزات إلى صفحة الدفع الخاصة بالشركة حتى تبقى علاقة العميل والتحصيل المالي بيد الشركة نفسها.'],
].map(([title, body]) => ({ title: title!, body: body! })),
metrics: [
['01', 'ظهور مشترك في السوق'],
['02', 'دفع مباشر للشركة'],
['03', 'مدفوعات مملوكة للشركة'],
].map(([value, label]) => ({ value: value!, label: label! })),
featureLabel: 'ما الذي يقدمه المنتج',
features: [
'إدارة الأسطول مع رفع عدة صور',
'عروض السوق والتحويل إلى مسار الحجز',
'موقع حجز عام مخصص لكل شركة',
'إدارة العملاء والتحليلات والفوترة',
],
stepsTitle: 'كيف تبدأ الشركات',
steps: [
['1', 'أنشئ مساحة عمل شركتك', 'اختر الخطة وابدأ تجربتك لمدة 14 يوماً ثم فعّل حساب المالك.'],
['2', 'انشر السيارات والعروض', 'ارفع الصور مرة واحدة وتحكم فيما يظهر في السوق وفي الموقع المخصص.'],
['3', 'استقبل الحجوزات على موقعك', 'يكتشف المستأجرون أسطولك على RentalDriveGo ثم يدفعون مباشرة عبر مسار الحجز الخاص بك.'],
].map(([step, title, body]) => ({ step: step!, title: title!, body: body! })),
stepLabel: 'الخطوة',
readyKicker: 'جاهز للانطلاق',
readyTitle: 'يجب أن تشرح الصفحة الرئيسية قيمة المنصة خلال ثوانٍ.',
readyBody:
'استخدم السوق لجذب الطلب، ثم انقل المستأجرين إلى تجربة تحمل هوية شركتك، بحيث تبقى الأسعار والمدفوعات والثقة مرتبطة بنشاطك.',
viewPricing: 'عرض الأسعار',
createWorkspace: 'إنشاء المساحة',
},
}
export function cloneMarketplaceHomepageContent(): MarketplaceHomepageConfig {
return JSON.parse(JSON.stringify(defaultMarketplaceHomepageContent)) as MarketplaceHomepageConfig
}
export function resolveMarketplaceHomepageSections(
sections?: MarketplaceHomepageSectionType[] | null,
): MarketplaceHomepageSectionType[] {
const source = sections?.length ? sections : defaultMarketplaceHomepageSections
return source.filter((section, index) => source.indexOf(section) === index)
}
+11
View File
@@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"declaration": true,
"declarationMap": false,
"sourceMap": false
},
"include": ["src/**/*.ts"]
}