diff --git a/apps/api/src/utils/redis/redis.ts b/apps/api/src/utils/redis/redis.ts index 29af0219..7fdbf274 100644 --- a/apps/api/src/utils/redis/redis.ts +++ b/apps/api/src/utils/redis/redis.ts @@ -1,8 +1,8 @@ -import { createClient } from 'redis'; +import { createClient, type RedisClientType } from 'redis'; import { env } from '@api/config/env'; // Create Redis client -const client = createClient({ +const client: RedisClientType = createClient({ url: env.REDIS_URL }); @@ -30,7 +30,7 @@ export async function connectRedis(): Promise { } // Export the client (connection will be established before server starts) -export const redis = client; +export const redis: RedisClientType = client; // Export type for use in other files -export type RedisClient = typeof client; +export type RedisClient = RedisClientType; diff --git a/packages/utils/package.json b/packages/utils/package.json index 702a28a4..0a4719f5 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -59,6 +59,7 @@ "@cio/tsconfig": "workspace:*" }, "devDependencies": { + "tsc-alias": "^1.8.16", "tsx": "^4.7.1", "typescript": "^5.5.2", "zod": "^4.1.8" diff --git a/packages/utils/src/license/constants.ts b/packages/utils/src/license/constants.ts index 81266b45..50e164f6 100644 --- a/packages/utils/src/license/constants.ts +++ b/packages/utils/src/license/constants.ts @@ -15,4 +15,6 @@ export const LICENSE_FEATURE = { export type LicenseFeatureId = (typeof LICENSE_FEATURE)[keyof typeof LICENSE_FEATURE]; /** All known license feature IDs for validation */ -export const LICENSE_FEATURE_IDS = Object.values(LICENSE_FEATURE) as readonly string[]; +export const LICENSE_FEATURE_IDS = Object.keys(LICENSE_FEATURE).map( + (key) => LICENSE_FEATURE[key as keyof typeof LICENSE_FEATURE] +) as readonly LicenseFeatureId[]; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fbd8182e..178a5db8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -646,7 +646,7 @@ importers: version: 3.3.1(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@24.10.4)(lightningcss@1.31.1)(sass@1.97.1)))(svelte@5.46.0)(vite@5.4.21(@types/node@24.10.4)(lightningcss@1.31.1)(sass@1.97.1))) '@sveltejs/adapter-vercel': specifier: ^4.0.0 - version: 4.0.5(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@24.10.4)(lightningcss@1.31.1)(sass@1.97.1)))(svelte@5.46.0)(vite@5.4.21(@types/node@24.10.4)(lightningcss@1.31.1)(sass@1.97.1)))(encoding@0.1.13) + version: 4.0.5(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@24.10.4)(lightningcss@1.31.1)(sass@1.97.1)))(svelte@5.46.0)(vite@5.4.21(@types/node@24.10.4)(lightningcss@1.31.1)(sass@1.97.1))) '@sveltejs/kit': specifier: ^2.5.27 version: 2.49.2(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@24.10.4)(lightningcss@1.31.1)(sass@1.97.1)))(svelte@5.46.0)(vite@5.4.21(@types/node@24.10.4)(lightningcss@1.31.1)(sass@1.97.1)) @@ -1191,6 +1191,9 @@ importers: specifier: workspace:* version: link:../tsconfig devDependencies: + tsc-alias: + specifier: ^1.8.16 + version: 1.8.16 tsx: specifier: ^4.7.1 version: 4.21.0 @@ -13960,16 +13963,6 @@ snapshots: samlify: 2.10.2 zod: 4.3.6 - '@better-auth/sso@1.4.18(@better-auth/utils@0.3.0)(better-auth@1.4.9(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1)))(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1)))(@tanstack/react-start@1.143.4(crossws@0.4.1(srvx@0.9.8))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1)))(drizzle-kit@0.31.8)(drizzle-orm@0.44.7(@types/pg@8.16.0)(kysely@0.28.9)(pg@8.18.0)(postgres@3.4.7))(pg@8.18.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(solid-js@1.9.10)(svelte@5.46.0)(vitest@2.1.9(@types/node@20.19.27)(jsdom@26.1.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(lightningcss@1.31.1)(sass@1.97.1))(vue@3.5.26(typescript@5.9.3)))': - dependencies: - '@better-auth/utils': 0.3.0 - '@better-fetch/fetch': 1.1.21 - better-auth: 1.4.9(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1)))(svelte@5.46.0)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1)))(@tanstack/react-start@1.143.4(crossws@0.4.1(srvx@0.9.8))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.31.1)(sass@1.97.1)))(drizzle-kit@0.31.8)(drizzle-orm@0.44.7(@types/pg@8.16.0)(kysely@0.28.9)(pg@8.18.0)(postgres@3.4.7))(pg@8.18.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(solid-js@1.9.10)(svelte@5.46.0)(vitest@2.1.9(@types/node@20.19.27)(jsdom@26.1.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(lightningcss@1.31.1)(sass@1.97.1))(vue@3.5.26(typescript@5.9.3)) - fast-xml-parser: 5.2.5 - jose: 6.1.3 - samlify: 2.10.2 - zod: 4.3.6 - '@better-auth/telemetry@1.4.18(@better-auth/core@1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.9)(nanostores@1.1.0))': dependencies: '@better-auth/core': 1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.9)(nanostores@1.1.0) @@ -16808,7 +16801,7 @@ snapshots: - encoding - supports-color - '@sveltejs/adapter-vercel@4.0.5(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@24.10.4)(lightningcss@1.31.1)(sass@1.97.1)))(svelte@5.46.0)(vite@5.4.21(@types/node@24.10.4)(lightningcss@1.31.1)(sass@1.97.1)))(encoding@0.1.13)': + '@sveltejs/adapter-vercel@4.0.5(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@24.10.4)(lightningcss@1.31.1)(sass@1.97.1)))(svelte@5.46.0)(vite@5.4.21(@types/node@24.10.4)(lightningcss@1.31.1)(sass@1.97.1)))': dependencies: '@sveltejs/kit': 2.49.2(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.46.0)(vite@5.4.21(@types/node@24.10.4)(lightningcss@1.31.1)(sass@1.97.1)))(svelte@5.46.0)(vite@5.4.21(@types/node@24.10.4)(lightningcss@1.31.1)(sass@1.97.1)) '@vercel/nft': 0.26.5(encoding@0.1.13) diff --git a/railway.json b/railway.json index aff8ff3c..f7115dea 100644 --- a/railway.json +++ b/railway.json @@ -1,13 +1,14 @@ { "$schema": "https://railway.com/railway.schema.json", "build": { - "builder": "NIXPACKS", - "buildCommand": "pnpm build --filter @cio/dashboard" + "builder": "RAILPACK", + "buildCommand": "sh -lc 'case \"${SERVICE_TARGET:-api}\" in api) pnpm -r --filter @cio/api... build ;; dashboard) NODE_OPTIONS=\"--max-old-space-size=3072\" pnpm -r --filter @cio/dashboard... build ;; *) echo \"Unsupported SERVICE_TARGET: ${SERVICE_TARGET:-api}\" >&2; exit 1 ;; esac'" }, "deploy": { - "startCommand": "pnpm dashboard:start", + "preDeployCommand": "sh -lc 'if [ \"${SERVICE_TARGET:-api}\" = \"api\" ]; then pnpm --filter @cio/db db:setup; else echo \"Skipping db setup for ${SERVICE_TARGET:-api}\"; fi'", + "startCommand": "sh -lc 'case \"${SERVICE_TARGET:-api}\" in api) exec pnpm --filter @cio/api start ;; dashboard) exec pnpm --filter @cio/dashboard start ;; *) echo \"Unsupported SERVICE_TARGET: ${SERVICE_TARGET:-api}\" >&2; exit 1 ;; esac'", "healthcheckPath": "/", "healthcheckTimeout": 100, - "restartPolicyType": "never" + "restartPolicyType": "ALWAYS" } } diff --git a/setup-railway-stack.sh b/setup-railway-stack.sh new file mode 100644 index 00000000..1f63b8ad --- /dev/null +++ b/setup-railway-stack.sh @@ -0,0 +1,229 @@ +#!/usr/bin/env bash + +set -euo pipefail + +API_SERVICE_NAME="${API_SERVICE_NAME:-cio-api}" +DASHBOARD_SERVICE_NAME="${DASHBOARD_SERVICE_NAME:-cio-dashboard}" +POSTGRES_SERVICE_NAME="${POSTGRES_SERVICE_NAME:-Postgres}" +REDIS_SERVICE_NAME="${REDIS_SERVICE_NAME:-Redis}" + +BUCKET_REGION="${BUCKET_REGION:-iad}" +BUCKET_PREFIX="${BUCKET_PREFIX:-classroomio}" +VIDEOS_BUCKET_NAME="${VIDEOS_BUCKET_NAME:-${BUCKET_PREFIX}-videos}" +DOCUMENTS_BUCKET_NAME="${DOCUMENTS_BUCKET_NAME:-${BUCKET_PREFIX}-documents}" +MEDIA_BUCKET_NAME="${MEDIA_BUCKET_NAME:-${BUCKET_PREFIX}-media}" + +require_command() { + local command_name="$1" + if ! command -v "${command_name}" >/dev/null 2>&1; then + echo "Error: ${command_name} is required but not installed." + exit 1 + fi +} + +json_has_name() { + local json_payload="$1" + local target_name="$2" + + printf '%s' "${json_payload}" | jq -e --arg target "${target_name}" ' + [ + .. | objects | ( + .name? // + .serviceName? // + .service?.name? // + empty + ) + ] | index($target) != null + ' >/dev/null 2>&1 +} + +service_exists() { + local service_name="$1" + local service_json + + service_json="$(railway service status --all --json 2>/dev/null || true)" + if [[ -z "${service_json}" ]]; then + return 1 + fi + + json_has_name "${service_json}" "${service_name}" +} + +bucket_exists() { + local bucket_name="$1" + local bucket_json + + bucket_json="$(railway bucket list --json 2>/dev/null || true)" + if [[ -z "${bucket_json}" ]]; then + return 1 + fi + + json_has_name "${bucket_json}" "${bucket_name}" +} + +ensure_database_service() { + local database_kind="$1" + local service_name="$2" + local output + + if service_exists "${service_name}"; then + echo "Service '${service_name}' already exists." + return + fi + + echo "Creating ${database_kind} service '${service_name}'..." + if output="$(railway add --database "${database_kind}" --service "${service_name}" --json 2>&1)"; then + echo "Created '${service_name}'." + return + fi + + if service_exists "${service_name}"; then + echo "Service '${service_name}' already exists (detected after create attempt)." + return + fi + + echo "${output}" + echo "Error: failed to provision '${service_name}'." + exit 1 +} + +ensure_bucket() { + local bucket_name="$1" + local output + + if bucket_exists "${bucket_name}"; then + echo "Bucket '${bucket_name}' already exists." + return + fi + + echo "Creating bucket '${bucket_name}' in region '${BUCKET_REGION}'..." + if output="$(railway bucket create "${bucket_name}" --region "${BUCKET_REGION}" --json 2>&1)"; then + echo "Created bucket '${bucket_name}'." + return + fi + + if bucket_exists "${bucket_name}"; then + echo "Bucket '${bucket_name}' already exists (detected after create attempt)." + return + fi + + echo "${output}" + echo "Error: failed to create bucket '${bucket_name}'." + exit 1 +} + +ensure_application_service() { + local service_name="$1" + + if service_exists "${service_name}"; then + return + fi + + echo "Error: app service '${service_name}' was not found." + echo "Create/link it first, then rerun this script:" + echo " railway add --service \"${service_name}\"" + exit 1 +} + +build_media_public_base_url() { + local endpoint="$1" + local bucket_name="$2" + local force_path_style="$3" + local url + + url="${endpoint%/}" + if [[ "${force_path_style}" == "true" ]]; then + printf '%s/%s\n' "${url}" "${bucket_name}" + return + fi + + if [[ "${url}" =~ ^(https?)://(.+)$ ]]; then + printf '%s://%s.%s\n' "${BASH_REMATCH[1]}" "${bucket_name}" "${BASH_REMATCH[2]}" + return + fi + + printf '%s/%s\n' "${url}" "${bucket_name}" +} + +require_command railway +require_command jq + +if ! railway status --json >/dev/null 2>&1; then + echo "Error: no linked Railway project in this directory." + echo "Run 'railway link --project ' and retry." + exit 1 +fi + +ensure_application_service "${API_SERVICE_NAME}" +ensure_application_service "${DASHBOARD_SERVICE_NAME}" + +ensure_database_service postgres "${POSTGRES_SERVICE_NAME}" +ensure_database_service redis "${REDIS_SERVICE_NAME}" + +ensure_bucket "${VIDEOS_BUCKET_NAME}" +ensure_bucket "${DOCUMENTS_BUCKET_NAME}" +ensure_bucket "${MEDIA_BUCKET_NAME}" + +echo "Fetching credentials for bucket '${MEDIA_BUCKET_NAME}'..." +MEDIA_BUCKET_CREDS_JSON="$(railway bucket credentials --bucket "${MEDIA_BUCKET_NAME}" --json)" + +OBJECT_STORAGE_ENDPOINT="$(printf '%s' "${MEDIA_BUCKET_CREDS_JSON}" | jq -r '.endpoint // empty')" +OBJECT_STORAGE_ACCESS_KEY_ID="$(printf '%s' "${MEDIA_BUCKET_CREDS_JSON}" | jq -r '.accessKeyId // empty')" +OBJECT_STORAGE_SECRET_ACCESS_KEY="$(printf '%s' "${MEDIA_BUCKET_CREDS_JSON}" | jq -r '.secretAccessKey // empty')" +OBJECT_STORAGE_REGION="${BUCKET_REGION}" +OBJECT_STORAGE_URL_STYLE="$(printf '%s' "${MEDIA_BUCKET_CREDS_JSON}" | jq -r '(.urlStyle // "") | ascii_downcase')" + +if [[ -z "${OBJECT_STORAGE_ENDPOINT}" || -z "${OBJECT_STORAGE_ACCESS_KEY_ID}" || -z "${OBJECT_STORAGE_SECRET_ACCESS_KEY}" ]]; then + echo "Error: could not parse bucket credentials JSON." + echo "Response:" + echo "${MEDIA_BUCKET_CREDS_JSON}" + exit 1 +fi + +OBJECT_STORAGE_FORCE_PATH_STYLE="false" +if [[ "${OBJECT_STORAGE_URL_STYLE}" == *path* ]]; then + OBJECT_STORAGE_FORCE_PATH_STYLE="true" +fi + +OBJECT_STORAGE_MEDIA_PUBLIC_BASE_URL="$( + build_media_public_base_url "${OBJECT_STORAGE_ENDPOINT}" "${MEDIA_BUCKET_NAME}" "${OBJECT_STORAGE_FORCE_PATH_STYLE}" +)" + +POSTGRES_DATABASE_URL_REF="\${{${POSTGRES_SERVICE_NAME}.DATABASE_URL}}" +REDIS_URL_REF="\${{${REDIS_SERVICE_NAME}.REDIS_URL}}" + +echo "Setting API variables on '${API_SERVICE_NAME}'..." +railway variable set --service "${API_SERVICE_NAME}" \ + "SERVICE_TARGET=api" \ + "PORT=3081" \ + "DATABASE_URL=${POSTGRES_DATABASE_URL_REF}" \ + "PRIVATE_DATABASE_URL=${POSTGRES_DATABASE_URL_REF}" \ + "REDIS_URL=${REDIS_URL_REF}" \ + "OBJECT_STORAGE_ENDPOINT=${OBJECT_STORAGE_ENDPOINT%/}" \ + "OBJECT_STORAGE_PUBLIC_ENDPOINT=${OBJECT_STORAGE_ENDPOINT%/}" \ + "OBJECT_STORAGE_ACCESS_KEY_ID=${OBJECT_STORAGE_ACCESS_KEY_ID}" \ + "OBJECT_STORAGE_SECRET_ACCESS_KEY=${OBJECT_STORAGE_SECRET_ACCESS_KEY}" \ + "OBJECT_STORAGE_REGION=${OBJECT_STORAGE_REGION}" \ + "OBJECT_STORAGE_FORCE_PATH_STYLE=${OBJECT_STORAGE_FORCE_PATH_STYLE}" \ + "OBJECT_STORAGE_BUCKET_VIDEOS=${VIDEOS_BUCKET_NAME}" \ + "OBJECT_STORAGE_BUCKET_DOCUMENTS=${DOCUMENTS_BUCKET_NAME}" \ + "OBJECT_STORAGE_BUCKET_MEDIA=${MEDIA_BUCKET_NAME}" \ + "OBJECT_STORAGE_MEDIA_PUBLIC_BASE_URL=${OBJECT_STORAGE_MEDIA_PUBLIC_BASE_URL}" + +echo "Setting dashboard variables on '${DASHBOARD_SERVICE_NAME}'..." +railway variable set --service "${DASHBOARD_SERVICE_NAME}" \ + "SERVICE_TARGET=dashboard" \ + "PORT=3082" \ + "PUBLIC_IS_SELFHOSTED=true" + +echo +echo "Railway infrastructure is provisioned and wired:" +echo "- Postgres service: ${POSTGRES_SERVICE_NAME}" +echo "- Redis service: ${REDIS_SERVICE_NAME}" +echo "- Buckets: ${VIDEOS_BUCKET_NAME}, ${DOCUMENTS_BUCKET_NAME}, ${MEDIA_BUCKET_NAME}" +echo "- API service updated: ${API_SERVICE_NAME}" +echo "- Dashboard service updated: ${DASHBOARD_SERVICE_NAME}" +echo +echo "Next: set domain/auth/smtp variables, then deploy with:" +echo " railway up --service \"${API_SERVICE_NAME}\" --detach -m \"deploy api\"" +echo " railway up --service \"${DASHBOARD_SERVICE_NAME}\" --detach -m \"deploy dashboard\""