Compare commits
10 Commits
v2026.01.0
...
v2026.01.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8dbc24f335 | ||
|
|
2953bb9f04 | ||
|
|
beff2d1b4b | ||
|
|
1ec5e5f189 | ||
|
|
d87210f8d3 | ||
|
|
022d0c9529 | ||
|
|
e20be988ce | ||
|
|
0480f398ac | ||
|
|
7df625b65f | ||
|
|
c8ecda67bd |
23
.env.dev
Normal file
23
.env.dev
Normal file
@@ -0,0 +1,23 @@
|
||||
# Development Environment Variables
|
||||
IMAGE_TAG=dev
|
||||
DOCKER_REGISTRY=localhost
|
||||
DOCKER_USERNAME=basil
|
||||
|
||||
# Database - uses local postgres from docker-compose
|
||||
DATABASE_URL=postgresql://basil:basil@postgres:5432/basil?schema=public
|
||||
|
||||
# CORS for local development
|
||||
CORS_ORIGIN=http://localhost
|
||||
|
||||
# JWT Secrets (dev only - not secure)
|
||||
JWT_SECRET=dev-secret-change-this-in-production-min-32-chars
|
||||
JWT_REFRESH_SECRET=dev-refresh-secret-change-this-in-prod-min-32
|
||||
|
||||
# Google OAuth (optional for dev)
|
||||
GOOGLE_CLIENT_ID=
|
||||
GOOGLE_CLIENT_SECRET=
|
||||
GOOGLE_CALLBACK_URL=http://localhost/api/auth/google/callback
|
||||
|
||||
# Application URLs
|
||||
APP_URL=http://localhost
|
||||
API_URL=http://localhost/api
|
||||
@@ -398,7 +398,8 @@ jobs:
|
||||
name: Trigger Deployment
|
||||
runs-on: ubuntu-latest
|
||||
needs: docker-build-and-push
|
||||
if: success()
|
||||
# Skip deployment if commit message contains [skip-deploy] or [dev]
|
||||
if: success() && !contains(github.event.head_commit.message, '[skip-deploy]') && !contains(github.event.head_commit.message, '[dev]')
|
||||
steps:
|
||||
- name: Trigger webhook
|
||||
run: |
|
||||
|
||||
54
CLAUDE.md
54
CLAUDE.md
@@ -270,3 +270,57 @@ Basil includes a complete CI/CD pipeline with Gitea Actions for automated testin
|
||||
- `DOCKER_USERNAME` - Docker Hub username
|
||||
- `DOCKER_PASSWORD` - Docker Hub access token
|
||||
- `DEPLOY_WEBHOOK_URL` - Webhook endpoint for deployments
|
||||
|
||||
## Version Management
|
||||
|
||||
**IMPORTANT**: Increment the version with **every production deployment**.
|
||||
|
||||
### Version Format
|
||||
Basil uses calendar versioning with the format: `YYYY.MM.PPP`
|
||||
- `YYYY` - Four-digit year (e.g., 2026)
|
||||
- `MM` - Two-digit month with zero-padding (e.g., 01 for January, 12 for December)
|
||||
- `PPP` - Three-digit patch number with zero-padding that increases with each deployment in a month
|
||||
|
||||
### Examples
|
||||
- `2026.01.001` - First deployment in January 2026
|
||||
- `2026.01.002` - Second deployment in January 2026
|
||||
- `2026.02.001` - First deployment in February 2026 (patch resets to 001)
|
||||
- `2026.02.003` - Third deployment in February 2026
|
||||
|
||||
### Version Update Process
|
||||
When deploying to production:
|
||||
|
||||
1. **Update version files:**
|
||||
```bash
|
||||
# Update both version files with new version
|
||||
# packages/api/src/version.ts
|
||||
# packages/web/src/version.ts
|
||||
export const APP_VERSION = '2026.01.002';
|
||||
```
|
||||
|
||||
2. **Commit the version bump:**
|
||||
```bash
|
||||
git add packages/api/src/version.ts packages/web/src/version.ts
|
||||
git commit -m "chore: bump version to 2026.01.002"
|
||||
git push origin main
|
||||
```
|
||||
|
||||
3. **Create Git tag and release:**
|
||||
```bash
|
||||
# Tag should match version with 'v' prefix
|
||||
git tag v2026.01.002
|
||||
git push origin v2026.01.002
|
||||
|
||||
# Or use Gitea MCP to create tag and release
|
||||
```
|
||||
|
||||
4. **Document in release notes:**
|
||||
- Summarize changes since last version
|
||||
- List bug fixes, features, and breaking changes
|
||||
- Reference related pull requests or issues
|
||||
|
||||
### Version Display
|
||||
The current version is displayed in:
|
||||
- API: `GET /api/version` endpoint returns `{ version: '2026.01.002' }`
|
||||
- Web: Footer or about section shows current version
|
||||
- Both packages export `APP_VERSION` constant for internal use
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"""
|
||||
Recipe scraper script using the recipe-scrapers library.
|
||||
This script is called by the Node.js API to scrape recipes from URLs.
|
||||
Uses wild mode (supported_only=False) to work with any website, not just officially supported ones.
|
||||
Uses wild mode (supported_only=False) to work with any website that uses schema.org structured data.
|
||||
"""
|
||||
|
||||
import sys
|
||||
@@ -52,8 +52,8 @@ def scrape_recipe(url):
|
||||
html = fetch_html(url)
|
||||
|
||||
# Use scrape_html to scrape the recipe
|
||||
# Works with officially supported websites
|
||||
scraper = scrape_html(html, org_url=url)
|
||||
# supported_only=False enables wild mode for any website with schema.org data
|
||||
scraper = scrape_html(html, org_url=url, supported_only=False)
|
||||
|
||||
# Extract recipe data with safe extraction
|
||||
recipe_data = {
|
||||
|
||||
@@ -390,7 +390,7 @@ describe('Recipes Routes - Real Integration Tests', () => {
|
||||
expect(prisma.recipe.update).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should delete old relations before update', async () => {
|
||||
it('should only delete relations that are being updated', async () => {
|
||||
(prisma.recipeSection.deleteMany as any).mockResolvedValue({});
|
||||
(prisma.ingredient.deleteMany as any).mockResolvedValue({});
|
||||
(prisma.instruction.deleteMany as any).mockResolvedValue({});
|
||||
@@ -406,22 +406,46 @@ describe('Recipes Routes - Real Integration Tests', () => {
|
||||
});
|
||||
(prisma.cookbook.findMany as any).mockResolvedValue([]);
|
||||
|
||||
// Test 1: Only updating title - should not delete any relations
|
||||
await request(app)
|
||||
.put('/api/recipes/1')
|
||||
.send({ title: 'Updated' });
|
||||
|
||||
expect(prisma.recipeSection.deleteMany).toHaveBeenCalledWith({
|
||||
where: { recipeId: '1' },
|
||||
expect(prisma.recipeSection.deleteMany).not.toHaveBeenCalled();
|
||||
expect(prisma.ingredient.deleteMany).not.toHaveBeenCalled();
|
||||
expect(prisma.instruction.deleteMany).not.toHaveBeenCalled();
|
||||
expect(prisma.recipeTag.deleteMany).not.toHaveBeenCalled();
|
||||
|
||||
// Reset mocks
|
||||
vi.clearAllMocks();
|
||||
(prisma.ingredient.deleteMany as any).mockResolvedValue({});
|
||||
(prisma.recipeTag.deleteMany as any).mockResolvedValue({});
|
||||
(prisma.recipe.update as any).mockResolvedValue({
|
||||
id: '1',
|
||||
title: 'Updated',
|
||||
ingredients: [],
|
||||
tags: [],
|
||||
});
|
||||
(prisma.cookbook.findMany as any).mockResolvedValue([]);
|
||||
|
||||
// Test 2: Updating tags and ingredients - should only delete those
|
||||
await request(app)
|
||||
.put('/api/recipes/1')
|
||||
.send({
|
||||
title: 'Updated',
|
||||
ingredients: [],
|
||||
tags: []
|
||||
});
|
||||
|
||||
expect(prisma.ingredient.deleteMany).toHaveBeenCalledWith({
|
||||
where: { recipeId: '1' },
|
||||
});
|
||||
expect(prisma.instruction.deleteMany).toHaveBeenCalledWith({
|
||||
where: { recipeId: '1' },
|
||||
});
|
||||
expect(prisma.recipeTag.deleteMany).toHaveBeenCalledWith({
|
||||
where: { recipeId: '1' },
|
||||
});
|
||||
// These should NOT be called since we didn't send them
|
||||
expect(prisma.recipeSection.deleteMany).not.toHaveBeenCalled();
|
||||
expect(prisma.instruction.deleteMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return 500 on update error', async () => {
|
||||
|
||||
@@ -363,11 +363,19 @@ router.put('/:id', async (req, res) => {
|
||||
try {
|
||||
const { sections, ingredients, instructions, tags, ...recipeData } = req.body;
|
||||
|
||||
// Delete existing relations
|
||||
await prisma.recipeSection.deleteMany({ where: { recipeId: req.params.id } });
|
||||
await prisma.ingredient.deleteMany({ where: { recipeId: req.params.id } });
|
||||
await prisma.instruction.deleteMany({ where: { recipeId: req.params.id } });
|
||||
await prisma.recipeTag.deleteMany({ where: { recipeId: req.params.id } });
|
||||
// Only delete relations that are being updated (not undefined)
|
||||
if (sections !== undefined) {
|
||||
await prisma.recipeSection.deleteMany({ where: { recipeId: req.params.id } });
|
||||
}
|
||||
if (ingredients !== undefined) {
|
||||
await prisma.ingredient.deleteMany({ where: { recipeId: req.params.id } });
|
||||
}
|
||||
if (instructions !== undefined) {
|
||||
await prisma.instruction.deleteMany({ where: { recipeId: req.params.id } });
|
||||
}
|
||||
if (tags !== undefined) {
|
||||
await prisma.recipeTag.deleteMany({ where: { recipeId: req.params.id } });
|
||||
}
|
||||
|
||||
// Helper to clean IDs from nested data
|
||||
const cleanIngredient = (ing: any, index: number) => ({
|
||||
|
||||
@@ -51,6 +51,11 @@ export class StorageService {
|
||||
}
|
||||
|
||||
async deleteFile(fileUrl: string): Promise<void> {
|
||||
// Skip deletion if this is an external URL (from imported recipes)
|
||||
if (fileUrl.startsWith('http://') || fileUrl.startsWith('https://')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (storageConfig.type === 'local') {
|
||||
const basePath = storageConfig.localPath || './uploads';
|
||||
const filePath = path.join(basePath, fileUrl.replace('/uploads/', ''));
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* Application version following the pattern: YYYY.MM.PATCH
|
||||
* Example: 2026.01.1 (January 2026, patch 1)
|
||||
* Application version following the pattern: YYYY.MM.PPP
|
||||
* Example: 2026.01.002 (January 2026, patch 2), 2026.02.003 (February 2026, patch 3)
|
||||
* Month and patch are zero-padded. Patch increments with each deployment in a month.
|
||||
*/
|
||||
export const APP_VERSION = '2026.01.1';
|
||||
export const APP_VERSION = '2026.01.004';
|
||||
|
||||
@@ -141,36 +141,62 @@ function RecipeDetail() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Optimistically update the UI immediately
|
||||
const optimisticTag = { tag: { id: 'temp', name: trimmedTag } };
|
||||
setRecipe({
|
||||
...recipe,
|
||||
tags: [...(recipe.tags || []), optimisticTag]
|
||||
});
|
||||
setTagInput('');
|
||||
// Keep focus in input field
|
||||
setTimeout(() => tagInputRef.current?.focus(), 0);
|
||||
|
||||
try {
|
||||
setSavingTags(true);
|
||||
// Send array of tag names (strings) to API
|
||||
const updatedTags = [...existingTagNames, trimmedTag];
|
||||
await recipesApi.update(id, { tags: updatedTags });
|
||||
|
||||
// Reload the recipe to get the updated tag structure from API
|
||||
await loadRecipe(id);
|
||||
setTagInput('');
|
||||
|
||||
// Reload available tags to include newly created ones
|
||||
loadTags();
|
||||
|
||||
// Keep focus in input field
|
||||
setTimeout(() => tagInputRef.current?.focus(), 0);
|
||||
// Fetch the updated recipe to get the proper tag IDs, but don't reload the whole page
|
||||
const response = await recipesApi.getById(id);
|
||||
if (response.data) {
|
||||
setRecipe(response.data);
|
||||
// Restore focus after state update
|
||||
setTimeout(() => tagInputRef.current?.focus(), 0);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to add tag:', err);
|
||||
alert('Failed to add tag');
|
||||
// Revert optimistic update on error
|
||||
await loadRecipe(id);
|
||||
} finally {
|
||||
setSavingTags(false);
|
||||
// Ensure focus is maintained
|
||||
setTimeout(() => tagInputRef.current?.focus(), 0);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveTag = async (tagToRemove: string) => {
|
||||
if (!id || !recipe) return;
|
||||
|
||||
// Optimistically update the UI immediately
|
||||
const previousTags = recipe.tags;
|
||||
const updatedTagsOptimistic = (recipe.tags || []).filter(tagItem => {
|
||||
const tagName = typeof tagItem === 'string' ? tagItem : tagItem.tag?.name || tagItem.name;
|
||||
return tagName !== tagToRemove;
|
||||
});
|
||||
setRecipe({
|
||||
...recipe,
|
||||
tags: updatedTagsOptimistic
|
||||
});
|
||||
|
||||
try {
|
||||
setSavingTags(true);
|
||||
// Convert existing tags to string array and filter out the removed tag
|
||||
const existingTagNames = (recipe.tags || [])
|
||||
const existingTagNames = (previousTags || [])
|
||||
.map(tagItem =>
|
||||
typeof tagItem === 'string' ? tagItem : tagItem.tag?.name || tagItem.name
|
||||
)
|
||||
@@ -178,11 +204,16 @@ function RecipeDetail() {
|
||||
const updatedTags = existingTagNames.filter(tag => tag !== tagToRemove);
|
||||
await recipesApi.update(id, { tags: updatedTags });
|
||||
|
||||
// Reload the recipe to get the updated tag structure from API
|
||||
await loadRecipe(id);
|
||||
// Fetch the updated recipe to get the proper tag structure
|
||||
const response = await recipesApi.getById(id);
|
||||
if (response.data) {
|
||||
setRecipe(response.data);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to remove tag:', err);
|
||||
alert('Failed to remove tag');
|
||||
// Revert optimistic update on error
|
||||
await loadRecipe(id);
|
||||
} finally {
|
||||
setSavingTags(false);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* Application version following the pattern: YYYY.MM.PATCH
|
||||
* Example: 2026.01.1 (January 2026, patch 1)
|
||||
* Application version following the pattern: YYYY.MM.PPP
|
||||
* Example: 2026.01.002 (January 2026, patch 2), 2026.02.003 (February 2026, patch 3)
|
||||
* Month and patch are zero-padded. Patch increments with each deployment in a month.
|
||||
*/
|
||||
export const APP_VERSION = '2026.01.1';
|
||||
export const APP_VERSION = '2026.01.004';
|
||||
|
||||
14
traefik-local/dynamic-dev.yml
Normal file
14
traefik-local/dynamic-dev.yml
Normal file
@@ -0,0 +1,14 @@
|
||||
http:
|
||||
routers:
|
||||
basil-dev:
|
||||
rule: "Host(`localhost`) || Host(`127.0.0.1`)"
|
||||
entryPoints:
|
||||
- http
|
||||
service: basil-dev-service
|
||||
priority: 1000 # Higher priority than Docker labels (default is 0)
|
||||
|
||||
services:
|
||||
basil-dev-service:
|
||||
loadBalancer:
|
||||
servers:
|
||||
- url: "http://basil-web:80"
|
||||
20
traefik-local/traefik.yml
Normal file
20
traefik-local/traefik.yml
Normal file
@@ -0,0 +1,20 @@
|
||||
# Static Traefik configuration for local development
|
||||
entryPoints:
|
||||
http:
|
||||
address: ":80"
|
||||
|
||||
providers:
|
||||
docker:
|
||||
endpoint: "unix:///var/run/docker.sock"
|
||||
exposedByDefault: false
|
||||
network: traefik
|
||||
file:
|
||||
filename: /dynamic-dev.yml
|
||||
watch: true
|
||||
|
||||
api:
|
||||
insecure: true
|
||||
dashboard: true
|
||||
|
||||
log:
|
||||
level: INFO
|
||||
Reference in New Issue
Block a user