Initial Commit

This commit is contained in:
Eric Gullickson
2025-12-17 15:43:32 -06:00
parent cd0cfa8913
commit b611b56336
16 changed files with 83 additions and 202 deletions

14
.github/FUNDING.yml vendored
View File

@@ -1,14 +0,0 @@
# These are supported funding model platforms
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
patreon:
open_collective: # Replace with a single Open Collective username
ko_fi: # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
polar: # Replace with a single Polar username
buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']

View File

@@ -1,171 +0,0 @@
name: CI
on:
push:
branches: [ main, master ]
pull_request:
branches: [ main, master ]
env:
DOCKER_BUILDKIT: 1
COMPOSE_DOCKER_CLI_BUILD: 1
COMPOSE_FILE: docker-compose.yml
COMPOSE_PROJECT_NAME: ci
jobs:
backend-tests:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Cache buildx layers
uses: actions/cache@v4
with:
path: ~/.cache/buildx
key: ${{ runner.os }}-buildx-${{ hashFiles('backend/package.json', 'frontend/package.json', 'package-lock.json') }}
restore-keys: |
${{ runner.os }}-buildx-
- name: Hydrate secrets and backend env
shell: bash
env:
POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
AUTH0_CLIENT_SECRET: ${{ secrets.AUTH0_CLIENT_SECRET }}
GOOGLE_MAPS_API_KEY: ${{ secrets.GOOGLE_MAPS_API_KEY }}
run: |
set -euo pipefail
mkdir -p .ci/secrets
: "${POSTGRES_PASSWORD:=$(cat secrets/app/postgres-password.txt 2>/dev/null || echo 'localdev123')}"
: "${AUTH0_CLIENT_SECRET:=$(cat secrets/app/auth0-client-secret.txt 2>/dev/null || echo 'ci-auth0-secret')}"
: "${GOOGLE_MAPS_API_KEY:=$(cat secrets/app/google-maps-api-key.txt 2>/dev/null || echo 'ci-google-maps-key')}"
mkdir -p secrets/app
printf '%s' "$POSTGRES_PASSWORD" > secrets/app/postgres-password.txt
printf '%s' "$AUTH0_CLIENT_SECRET" > secrets/app/auth0-client-secret.txt
printf '%s' "$GOOGLE_MAPS_API_KEY" > secrets/app/google-maps-api-key.txt
printf '%s' "$POSTGRES_PASSWORD" > .ci/secrets/postgres-password
printf '%s' "$AUTH0_CLIENT_SECRET" > .ci/secrets/auth0-client-secret
printf '%s' "$GOOGLE_MAPS_API_KEY" > .ci/secrets/google-maps-api-key
cat <<EOF_ENV > .ci/backend.env
NODE_ENV=test
CI=true
CONFIG_PATH=/ci/config/app/ci.yml
SECRETS_DIR=/ci/secrets
DATABASE_URL=postgresql://postgres:${POSTGRES_PASSWORD}@mvp-postgres:5432/motovaultpro
DB_HOST=mvp-postgres
DB_USER=postgres
DB_PASSWORD=${POSTGRES_PASSWORD}
DB_DATABASE=motovaultpro
DB_PORT=5432
REDIS_HOST=mvp-redis
REDIS_URL=redis://mvp-redis:6379
MINIO_ENDPOINT=http://minio:9000
GOOGLE_MAPS_API_KEY=${GOOGLE_MAPS_API_KEY}
AUTH0_CLIENT_SECRET=${AUTH0_CLIENT_SECRET}
EOF_ENV
- name: Build application images
run: docker compose build mvp-backend mvp-frontend
- name: Start database dependencies
run: docker compose up -d mvp-postgres mvp-redis
- name: Wait for dependencies
shell: bash
run: |
set -euo pipefail
for _ in $(seq 1 20); do
if docker compose exec -T mvp-postgres pg_isready -U postgres >/dev/null 2>&1; then
break
fi
sleep 3
done
docker compose exec -T mvp-postgres pg_isready -U postgres
docker compose exec -T mvp-redis redis-cli ping
- name: Build backend builder image
run: docker build --target builder -t motovaultpro-backend-builder backend
- name: Lint backend
run: |
docker run --rm \
--network ${COMPOSE_PROJECT_NAME}_default \
--env-file .ci/backend.env \
-v ${{ github.workspace }}/config/app/ci.yml:/ci/config/app/ci.yml:ro \
-v ${{ github.workspace }}/.ci/secrets:/ci/secrets:ro \
motovaultpro-backend-builder npm run lint
- name: Run backend tests
env:
CI: true
run: |
docker run --rm \
--network ${COMPOSE_PROJECT_NAME}_default \
--env-file .ci/backend.env \
-v ${{ github.workspace }}/config/app/ci.yml:/ci/config/app/ci.yml:ro \
-v ${{ github.workspace }}/.ci/secrets:/ci/secrets:ro \
motovaultpro-backend-builder npm test -- --runInBand
- name: Tear down containers
if: always()
run: docker compose down -v
frontend-tests:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Cache buildx layers
uses: actions/cache@v4
with:
path: ~/.cache/buildx
key: ${{ runner.os }}-buildx-${{ hashFiles('frontend/package.json', 'package-lock.json') }}
restore-keys: |
${{ runner.os }}-buildx-
- name: Prepare frontend env
shell: bash
run: |
set -euo pipefail
mkdir -p .ci
cat <<EOF_ENV > .ci/frontend.env
CI=true
VITE_AUTH0_DOMAIN=motovaultpro.us.auth0.com
VITE_AUTH0_CLIENT_ID=yspR8zdnSxmV8wFIghHynQ08iXAPoQJ3
VITE_AUTH0_AUDIENCE=https://api.motovaultpro.com
VITE_API_BASE_URL=/api
EOF_ENV
- name: Build frontend image
run: docker compose build mvp-frontend
- name: Build frontend dependencies image
run: docker build --target deps -t motovaultpro-frontend-deps frontend
- name: Lint frontend
run: |
docker run --rm \
--env-file .ci/frontend.env \
motovaultpro-frontend-deps npm run lint
- name: Run frontend tests
env:
CI: true
run: |
docker run --rm \
--env-file .ci/frontend.env \
motovaultpro-frontend-deps npm test -- --runInBand

View File

@@ -13,6 +13,9 @@ import {
} from './vehicles.types'; } from './vehicles.types';
import { logger } from '../../../core/logging/logger'; import { logger } from '../../../core/logging/logger';
import { cacheService } from '../../../core/config/redis'; import { cacheService } from '../../../core/config/redis';
import { getStorageService } from '../../../core/storage/storage.service';
import * as fs from 'fs/promises';
import * as path from 'path';
import { isValidVIN } from '../../../shared-minimal/utils/validators'; import { isValidVIN } from '../../../shared-minimal/utils/validators';
import { normalizeMakeName, normalizeModelName } from './name-normalizer'; import { normalizeMakeName, normalizeModelName } from './name-normalizer';
import { getVehicleDataService, getPool } from '../../platform'; import { getVehicleDataService, getPool } from '../../platform';
@@ -133,6 +136,59 @@ export class VehiclesService {
throw new Error('Unauthorized'); throw new Error('Unauthorized');
} }
logger.info('Deleting vehicle', {
vehicleId: id,
userId,
hasImageKey: !!existing.imageStorageKey,
hasImageBucket: !!existing.imageStorageBucket,
imageStorageKey: existing.imageStorageKey,
imageStorageBucket: existing.imageStorageBucket
});
// Delete associated image from storage if present
if (existing.imageStorageKey && existing.imageStorageBucket) {
try {
const storage = getStorageService();
logger.info('Attempting to delete vehicle image', {
vehicleId: id,
bucket: existing.imageStorageBucket,
key: existing.imageStorageKey
});
await storage.deleteObject(existing.imageStorageBucket, existing.imageStorageKey);
logger.info('Successfully deleted vehicle image on vehicle deletion', {
vehicleId: id,
bucket: existing.imageStorageBucket,
key: existing.imageStorageKey
});
// Clean up empty vehicle directory
// Key format: vehicle-images/{userId}/{vehicleId}/{filename}
const basePath = '/app/data/documents';
const vehicleDir = path.join(basePath, path.dirname(existing.imageStorageKey));
try {
await fs.rmdir(vehicleDir);
logger.info('Removed empty vehicle image directory', { vehicleId: id, directory: vehicleDir });
} catch (dirError) {
// Directory might not be empty or might not exist, ignore
logger.debug('Could not remove vehicle image directory', {
vehicleId: id,
directory: vehicleDir,
error: dirError instanceof Error ? dirError.message : String(dirError)
});
}
} catch (error) {
// Log warning but don't block vehicle deletion on storage failure
logger.warn('Failed to delete vehicle image on vehicle deletion', {
vehicleId: id,
bucket: existing.imageStorageBucket,
key: existing.imageStorageKey,
error: error instanceof Error ? error.message : String(error)
});
}
} else {
logger.info('No image to delete for vehicle', { vehicleId: id });
}
// Soft delete // Soft delete
await this.repository.softDelete(id); await this.repository.softDelete(id);

View File

@@ -146,6 +146,20 @@ export const StationsPage: React.FC = () => {
}; };
}, [savedStations]); }, [savedStations]);
// Compute which stations to display on map based on active tab
const mapStations = useMemo(() => {
if (tabValue === 1) {
// Saved tab: show saved stations with valid coordinates
return savedStations.filter(
(station) =>
station.latitude !== undefined &&
station.longitude !== undefined
) as Station[];
}
// Results tab: show search results
return searchResults;
}, [tabValue, savedStations, searchResults]);
// Handle search // Handle search
const handleSearch = (request: StationSearchRequest) => { const handleSearch = (request: StationSearchRequest) => {
setCurrentLocation({ latitude: request.latitude, longitude: request.longitude }); setCurrentLocation({ latitude: request.latitude, longitude: request.longitude });
@@ -246,7 +260,7 @@ export const StationsPage: React.FC = () => {
<GoogleMapsErrorBoundary> <GoogleMapsErrorBoundary>
<StationMap <StationMap
key="mobile-station-map" key="mobile-station-map"
stations={searchResults} stations={mapStations}
savedPlaceIds={savedPlaceIds} savedPlaceIds={savedPlaceIds}
currentLocation={currentLocation} currentLocation={currentLocation}
center={mapCenter || undefined} center={mapCenter || undefined}
@@ -315,7 +329,7 @@ export const StationsPage: React.FC = () => {
<GoogleMapsErrorBoundary> <GoogleMapsErrorBoundary>
<StationMap <StationMap
key="desktop-station-map" key="desktop-station-map"
stations={searchResults} stations={mapStations}
savedPlaceIds={savedPlaceIds} savedPlaceIds={savedPlaceIds}
currentLocation={currentLocation} currentLocation={currentLocation}
center={mapCenter || undefined} center={mapCenter || undefined}

View File

@@ -39,10 +39,18 @@ export const BottomNavigation: React.FC<BottomNavigationProps> = ({
}} }}
> >
{items.map(({ key, label, icon }) => ( {items.map(({ key, label, icon }) => (
<BottomNavigationAction <BottomNavigationAction
key={key} key={key}
label={label} label={label}
icon={icon} icon={icon}
sx={{
'& .MuiBottomNavigationAction-label': {
fontSize: '0.75rem',
'&.Mui-selected': {
fontSize: '0.75rem'
}
}
}}
/> />
))} ))}
</MuiBottomNavigation> </MuiBottomNavigation>