fix production trial issue
Build & Push / Pipeline Tests (push) Successful in 1m49s
Test / Type Check (all packages) (push) Successful in 55s
Build & Push / Build & Push Docker Image (push) Failing after 5m31s
Test / API Unit Tests (push) Successful in 1m20s
Test / Homepage Unit Tests (push) Successful in 48s
Test / Carplace Unit Tests (push) Successful in 45s
Test / Admin Unit Tests (push) Successful in 44s
Test / Dashboard Unit Tests (push) Successful in 46s
Test / API Integration Tests (push) Successful in 1m8s
Build & Push / Pipeline Tests (push) Successful in 1m49s
Test / Type Check (all packages) (push) Successful in 55s
Build & Push / Build & Push Docker Image (push) Failing after 5m31s
Test / API Unit Tests (push) Successful in 1m20s
Test / Homepage Unit Tests (push) Successful in 48s
Test / Carplace Unit Tests (push) Successful in 45s
Test / Admin Unit Tests (push) Successful in 44s
Test / Dashboard Unit Tests (push) Successful in 46s
Test / API Integration Tests (push) Successful in 1m8s
This commit is contained in:
@@ -447,6 +447,95 @@ jobs:
|
|||||||
ENV_DOCKER_PRODUCTION_RAW_B64="$(printf '%s' "$ENV_DOCKER_PRODUCTION" | base64 | tr -d '\n')"
|
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_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')"
|
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_USERNAME="${REGISTRY_USERNAME:-${REGISTRY_USER:-}}"
|
||||||
REGISTRY_PASSWORD="${REGISTRY_PASSWORD:-${REGISTRY_TOKEN:-}}"
|
REGISTRY_PASSWORD="${REGISTRY_PASSWORD:-${REGISTRY_TOKEN:-}}"
|
||||||
REGISTRY_PASSWORD_B64="$(printf '%s' "$REGISTRY_PASSWORD" | base64 | tr -d '\n')"
|
REGISTRY_PASSWORD_B64="$(printf '%s' "$REGISTRY_PASSWORD" | base64 | tr -d '\n')"
|
||||||
|
|||||||
@@ -375,4 +375,55 @@ describe('menu.service', () => {
|
|||||||
'settings',
|
'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',
|
||||||
|
])
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -621,6 +621,46 @@ function buildBaselineEmployeeMenu(role: EmployeeRole) {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function routeKey(routeOrUrl: string | null) {
|
||||||
|
return routeOrUrl === '/' ? '/' : routeOrUrl?.replace(/\/+$/, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasOnlyRecoveryOrUtilityRoutes(items: ReturnType<typeof buildMenuTree>) {
|
||||||
|
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<typeof buildMenuTree>, 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) {
|
export async function previewCompanyMenu(input: MenuPreviewInput) {
|
||||||
const context = await getMenuEvaluationContext(input.companyId, input.role)
|
const context = await getMenuEvaluationContext(input.companyId, input.role)
|
||||||
return {
|
return {
|
||||||
@@ -677,6 +717,8 @@ export async function getEmployeeMenu(employeeId: string) {
|
|||||||
const resolvedItems =
|
const resolvedItems =
|
||||||
context.subscriptionAccessLevel === 'full' && employee.isActive && !hasFeatureMenuRoute(menuItems)
|
context.subscriptionAccessLevel === 'full' && employee.isActive && !hasFeatureMenuRoute(menuItems)
|
||||||
? buildBaselineEmployeeMenu(employee.role)
|
? buildBaselineEmployeeMenu(employee.role)
|
||||||
|
: context.subscriptionAccessLevel === 'full' && employee.isActive && hasOnlyRecoveryOrUtilityRoutes(menuItems)
|
||||||
|
? mergeWithBaselineEmployeeMenu(menuItems, employee.role)
|
||||||
: menuItems
|
: menuItems
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -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 |
|
| `ACME_EMAIL` | Your email for Let's Encrypt notifications |
|
||||||
| `RESEND_API_KEY` | Resend API key (or configure SMTP vars instead) |
|
| `RESEND_API_KEY` | Resend API key (or configure SMTP vars instead) |
|
||||||
| `PGMANAGE_DOMAIN` | Hostname for pgManage, e.g. `pgmanage.rentaldrivego.ma` |
|
| `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:
|
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.
|
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 `/`.
|
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`.
|
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`.
|
||||||
|
|||||||
Reference in New Issue
Block a user