Initial Commit
14
.github/FUNDING.yml
vendored
@@ -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']
|
|
||||||
171
.github/workflows/ci.yml
vendored
@@ -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
|
|
||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 361 KiB |
@@ -1,4 +0,0 @@
|
|||||||
{
|
|
||||||
"x-original-filename": "C7Z06-BlackRose.jpg",
|
|
||||||
"content-type": "image/jpeg"
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 361 KiB |
@@ -1,4 +0,0 @@
|
|||||||
{
|
|
||||||
"x-original-filename": "C7Z06-BlackRose.jpg",
|
|
||||||
"content-type": "image/jpeg"
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 361 KiB |
@@ -1,4 +0,0 @@
|
|||||||
{
|
|
||||||
"x-original-filename": "C7Z06-BlackRose.jpg",
|
|
||||||
"content-type": "image/jpeg"
|
|
||||||
}
|
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -43,6 +43,14 @@ export const BottomNavigation: React.FC<BottomNavigationProps> = ({
|
|||||||
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>
|
||||||
|
|||||||