From 7bc1dd338ae076c13edd2ede0542f70dc450d6b9 Mon Sep 17 00:00:00 2001 From: root Date: Wed, 29 Jul 2026 16:05:01 -0400 Subject: [PATCH] fix production trial issue --- .gitea/workflows/build-and-deploy.yml | 89 +++++++++++++++++++ .../api/src/modules/menu/menu.service.test.ts | 51 +++++++++++ apps/api/src/modules/menu/menu.service.ts | 42 +++++++++ docs/DOCKER.md | 4 + 4 files changed, 186 insertions(+) diff --git a/.gitea/workflows/build-and-deploy.yml b/.gitea/workflows/build-and-deploy.yml index 16c0b27..37b143f 100644 --- a/.gitea/workflows/build-and-deploy.yml +++ b/.gitea/workflows/build-and-deploy.yml @@ -447,6 +447,95 @@ jobs: ENV_DOCKER_PRODUCTION_RAW_B64="$(printf '%s' "$ENV_DOCKER_PRODUCTION" | base64 | tr -d '\n')" STRIPE_API_KEY_B64="$(printf '%s' "$STRIPE_API_KEY" | base64 | tr -d '\n')" STRIPE_WEBHOOK_SECRET_B64="$(printf '%s' "$STRIPE_WEBHOOK_SECRET" | base64 | tr -d '\n')" + validate_stripe_secret_inputs() { + local env_file stripe_api_key stripe_webhook_secret + + env_file="$(mktemp)" + trap 'rm -f "$env_file"' EXIT + + if [ -n "$ENV_DOCKER_PRODUCTION_B64_CLEAN" ]; then + if ! printf '%s' "$ENV_DOCKER_PRODUCTION_B64_CLEAN" | base64 -d > "$env_file"; then + echo "::error::ENV_DOCKER_PRODUCTION_B64 is not valid base64" + exit 1 + fi + elif [ -n "$ENV_DOCKER_PRODUCTION" ]; then + printf '%s' "$ENV_DOCKER_PRODUCTION" > "$env_file" + else + : > "$env_file" + fi + + read_env_key() { + local key="$1" + awk -F= -v key="$key" ' + /^[[:space:]]*#/ || index($0, "=") == 0 { + next + } + { + name = $1 + sub(/^[[:space:]]*export[[:space:]]+/, "", name) + gsub(/^[[:space:]]+|[[:space:]]+$/, "", name) + if (name == key) { + value = substr($0, index($0, "=") + 1) + } + } + END { + print value + } + ' "$env_file" + } + + normalize_secret() { + local value="$1" + value="${value%$'\r'}" + value="${value#"${value%%[![:space:]]*}"}" + value="${value%"${value##*[![:space:]]}"}" + if [[ "$value" == \"*\" && "$value" == *\" ]]; then + value="${value#\"}" + value="${value%\"}" + elif [[ "$value" == \'*\' && "$value" == *\' ]]; then + value="${value#\'}" + value="${value%\'}" + fi + printf '%s' "$value" + } + + stripe_api_key="$(normalize_secret "${STRIPE_API_KEY:-$(read_env_key STRIPE_API_KEY)}")" + stripe_webhook_secret="$(normalize_secret "${STRIPE_WEBHOOK_SECRET:-$(read_env_key STRIPE_WEBHOOK_SECRET)}")" + + case "$stripe_api_key" in + sk_live_*|rk_live_*) ;; + "") + echo "::error::STRIPE_API_KEY is missing. Add a live sk_live_ or restricted rk_live_ key to the STRIPE_API_KEY secret, ENV_DOCKER_PRODUCTION, or ENV_DOCKER_PRODUCTION_B64." + exit 1 + ;; + placeholder|replace-with-*|*changeme*|*change-me*) + echo "::error::STRIPE_API_KEY is still a placeholder. Replace it with a live sk_live_ or restricted rk_live_ key in Gitea Actions secrets." + exit 1 + ;; + *) + echo "::error::STRIPE_API_KEY must start with sk_live_ or rk_live_ for production billing." + exit 1 + ;; + esac + + case "$stripe_webhook_secret" in + whsec_*) ;; + "") + echo "::error::STRIPE_WEBHOOK_SECRET is missing. Add the Stripe webhook signing secret to the STRIPE_WEBHOOK_SECRET secret, ENV_DOCKER_PRODUCTION, or ENV_DOCKER_PRODUCTION_B64." + exit 1 + ;; + placeholder|replace-with-*|*changeme*|*change-me*) + echo "::error::STRIPE_WEBHOOK_SECRET is still a placeholder. Replace it with the Stripe webhook signing secret in Gitea Actions secrets." + exit 1 + ;; + *) + echo "::error::STRIPE_WEBHOOK_SECRET must start with whsec_." + exit 1 + ;; + esac + } + + validate_stripe_secret_inputs REGISTRY_USERNAME="${REGISTRY_USERNAME:-${REGISTRY_USER:-}}" REGISTRY_PASSWORD="${REGISTRY_PASSWORD:-${REGISTRY_TOKEN:-}}" REGISTRY_PASSWORD_B64="$(printf '%s' "$REGISTRY_PASSWORD" | base64 | tr -d '\n')" diff --git a/apps/api/src/modules/menu/menu.service.test.ts b/apps/api/src/modules/menu/menu.service.test.ts index d6a4fe9..f97d925 100644 --- a/apps/api/src/modules/menu/menu.service.test.ts +++ b/apps/api/src/modules/menu/menu.service.test.ts @@ -375,4 +375,55 @@ describe('menu.service', () => { 'settings', ]) }) + + it('adds baseline routes when a full-access trial menu only exposes a small recovery menu', async () => { + vi.mocked(prisma.employee.findUniqueOrThrow).mockResolvedValue({ + id: 'employee_1', + role: 'OWNER', + isActive: true, + companyId: 'company_1', + } as never) + + vi.mocked(prisma.company.findUniqueOrThrow).mockResolvedValue({ + id: 'company_1', + name: 'Atlas Cars', + status: 'TRIALING', + subscription: { plan: 'STARTER', status: 'TRIALING' }, + } as never) + + vi.mocked(prisma.menuItem.findMany).mockResolvedValue([ + { + id: 'item_notifications', + systemKey: 'notifications', + label: 'Notifications', + itemType: 'INTERNAL_PAGE', + routeOrUrl: '/notifications', + icon: 'Bell', + parentId: null, + openInNewTab: false, + isRequired: false, + isActive: true, + displayOrder: 120, + roleVisibilities: [{ role: 'OWNER' }], + subscriptionAssignments: [{ plan: 'STARTER', displayOrder: 120, isActive: true }], + companyAssignments: [], + }, + ] as never) + + const result = await getEmployeeMenu('employee_1') + + expect(result.subscriptionStatus).toBe('TRIALING') + expect(result.subscriptionAccessLevel).toBe('full') + expect(result.items.map((menuItem) => menuItem.systemKey)).toEqual([ + 'dashboard', + 'reservations', + 'contracts', + 'fleet', + 'customers', + 'reports', + 'billing', + 'settings', + 'notifications', + ]) + }) }) diff --git a/apps/api/src/modules/menu/menu.service.ts b/apps/api/src/modules/menu/menu.service.ts index d9289c9..12a4335 100644 --- a/apps/api/src/modules/menu/menu.service.ts +++ b/apps/api/src/modules/menu/menu.service.ts @@ -621,6 +621,46 @@ function buildBaselineEmployeeMenu(role: EmployeeRole) { })) } +function routeKey(routeOrUrl: string | null) { + return routeOrUrl === '/' ? '/' : routeOrUrl?.replace(/\/+$/, '') +} + +function hasOnlyRecoveryOrUtilityRoutes(items: ReturnType) { + const internalRoutes = items + .filter((item) => item.itemType === 'INTERNAL_PAGE') + .map((item) => routeKey(item.routeOrUrl)) + .filter((route): route is string => Boolean(route)) + + if (internalRoutes.length === 0) return false + + return internalRoutes.every((route) => ( + route === '/' || + route === '/notifications' || + MENU_RECOVERY_ROUTES.has(route) + )) +} + +function mergeWithBaselineEmployeeMenu(items: ReturnType, role: EmployeeRole) { + const merged = [...items] + const seenRoutes = new Set( + merged + .filter((item) => item.itemType === 'INTERNAL_PAGE') + .map((item) => routeKey(item.routeOrUrl)) + .filter(Boolean), + ) + const seenSystemKeys = new Set(merged.map((item) => item.systemKey).filter(Boolean)) + + for (const baselineItem of buildBaselineEmployeeMenu(role)) { + const key = routeKey(baselineItem.routeOrUrl) + if (seenRoutes.has(key) || seenSystemKeys.has(baselineItem.systemKey)) continue + merged.push(baselineItem) + seenRoutes.add(key) + seenSystemKeys.add(baselineItem.systemKey) + } + + return sortByDisplayOrder(merged) +} + export async function previewCompanyMenu(input: MenuPreviewInput) { const context = await getMenuEvaluationContext(input.companyId, input.role) return { @@ -677,6 +717,8 @@ export async function getEmployeeMenu(employeeId: string) { const resolvedItems = context.subscriptionAccessLevel === 'full' && employee.isActive && !hasFeatureMenuRoute(menuItems) ? buildBaselineEmployeeMenu(employee.role) + : context.subscriptionAccessLevel === 'full' && employee.isActive && hasOnlyRecoveryOrUtilityRoutes(menuItems) + ? mergeWithBaselineEmployeeMenu(menuItems, employee.role) : menuItems return { diff --git a/docs/DOCKER.md b/docs/DOCKER.md index 6f598a6..f17d85e 100644 --- a/docs/DOCKER.md +++ b/docs/DOCKER.md @@ -161,6 +161,8 @@ Open `.env.docker.production` and fill in every value. The minimum required secr | `ACME_EMAIL` | Your email for Let's Encrypt notifications | | `RESEND_API_KEY` | Resend API key (or configure SMTP vars instead) | | `PGMANAGE_DOMAIN` | Hostname for pgManage, e.g. `pgmanage.rentaldrivego.ma` | +| `STRIPE_API_KEY` | Live Stripe secret key or restricted key for production billing (`sk_live_` or `rk_live_`) | +| `STRIPE_WEBHOOK_SECRET` | Stripe webhook signing secret for `/api/v1/subscriptions/webhooks/stripe` (`whsec_`) | For Gitea Actions deploys, either store the completed production env file as the raw `ENV_DOCKER_PRODUCTION` secret or as the base64-encoded `ENV_DOCKER_PRODUCTION_B64` secret: @@ -170,6 +172,8 @@ base64 < .env.docker.production | tr -d '\n' Paste that single-line output into `ENV_DOCKER_PRODUCTION_B64` when using the base64 option. During deploy, the workflow writes the env file to `/opt/rentaldrivego/.env.docker.production` on the VPS with `600` permissions before running `scripts/docker-prod-deploy.sh`. If neither production env secret is set, the workflow reuses `/opt/rentaldrivego/.env.docker.production` when it already exists on the VPS. +You can also store `STRIPE_API_KEY` and `STRIPE_WEBHOOK_SECRET` as separate Gitea Actions secrets. When those secrets are present, the deploy workflow overwrites the Stripe values from `ENV_DOCKER_PRODUCTION`/`ENV_DOCKER_PRODUCTION_B64` before deploying. This is useful when the production env file secret still contains placeholders for billing secrets. + Production now derives `DATABASE_URL` inside the app container from `POSTGRES_HOST`, `POSTGRES_PORT`, `POSTGRES_DB`, `POSTGRES_USER`, and `POSTGRES_PASSWORD` when `DATABASE_URL_FROM_POSTGRES=true`. That avoids Prisma auth failures when the database password contains reserved URL characters such as `@`, `:`, or `/`. The example file uses `rentaldrivego.ma` for the carplace and public site. The dashboard and admin panel are routed under that same host at `/dashboard` and `/admin`.