- Import real data from PDF (35 Bambu Lab filaments) - Remove all Confluence integration and dependencies - Implement new V2 data structure with proper inventory tracking - Add backwards compatibility for existing data - Create enhanced UI components (ColorSwatch, InventoryBadge, MaterialBadge) - Add advanced filtering with quick filters and multi-criteria search - Organize codebase for dev/prod environments - Update Lambda functions to support both V1/V2 formats - Add inventory summary dashboard - Clean up project structure and documentation 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
362 lines
11 KiB
JavaScript
362 lines
11 KiB
JavaScript
const AWS = require('aws-sdk');
|
|
const dynamodb = new AWS.DynamoDB.DocumentClient();
|
|
const { v4: uuidv4 } = require('uuid');
|
|
|
|
const TABLE_NAME = process.env.TABLE_NAME;
|
|
|
|
// CORS headers
|
|
const headers = {
|
|
'Content-Type': 'application/json',
|
|
'Access-Control-Allow-Origin': process.env.CORS_ORIGIN || '*',
|
|
'Access-Control-Allow-Headers': 'Content-Type,Authorization,X-Amz-Date,X-Api-Key,X-Amz-Security-Token',
|
|
'Access-Control-Allow-Methods': 'GET,POST,PUT,DELETE,OPTIONS',
|
|
'Access-Control-Allow-Credentials': 'true'
|
|
};
|
|
|
|
// Helper function to create response
|
|
const createResponse = (statusCode, body) => ({
|
|
statusCode,
|
|
headers,
|
|
body: JSON.stringify(body)
|
|
});
|
|
|
|
// Helper to transform new structure to legacy format for backwards compatibility
|
|
const transformToLegacy = (item) => {
|
|
if (item._legacy) {
|
|
// New structure - extract legacy fields
|
|
return {
|
|
id: item.id,
|
|
brand: item.brand,
|
|
tip: item._legacy.tip || item.type || item.material?.base,
|
|
finish: item._legacy.finish || item.material?.modifier || 'Basic',
|
|
boja: item._legacy.boja || item.color?.name,
|
|
refill: item._legacy.refill || (item.condition?.isRefill ? 'Da' : ''),
|
|
vakum: item._legacy.vakum || (item.condition?.storageCondition === 'vacuum' ? 'vakuum' : ''),
|
|
otvoreno: item._legacy.otvoreno || (item.condition?.storageCondition === 'opened' ? 'otvorena' : ''),
|
|
kolicina: item._legacy.kolicina || String(item.inventory?.total || ''),
|
|
cena: item._legacy.cena || String(item.pricing?.purchasePrice || ''),
|
|
status: item._legacy.status,
|
|
createdAt: item.createdAt,
|
|
updatedAt: item.updatedAt
|
|
};
|
|
}
|
|
// Already in legacy format
|
|
return item;
|
|
};
|
|
|
|
// GET all filaments or filter by query params
|
|
const getFilaments = async (event) => {
|
|
try {
|
|
const queryParams = event.queryStringParameters || {};
|
|
|
|
let params = {
|
|
TableName: TABLE_NAME
|
|
};
|
|
|
|
// Support both old and new query parameters
|
|
const brand = queryParams.brand;
|
|
const materialBase = queryParams.material || queryParams.tip;
|
|
const storageCondition = queryParams.storageCondition || queryParams.status;
|
|
|
|
// Filter by brand
|
|
if (brand) {
|
|
params = {
|
|
...params,
|
|
IndexName: 'brand-index',
|
|
KeyConditionExpression: 'brand = :brand',
|
|
ExpressionAttributeValues: {
|
|
':brand': brand
|
|
}
|
|
};
|
|
}
|
|
// Filter by material (supports old 'tip' param)
|
|
else if (materialBase) {
|
|
// Try new index first, fall back to old
|
|
try {
|
|
params = {
|
|
...params,
|
|
IndexName: 'material-base-index',
|
|
KeyConditionExpression: '#mb = :mb',
|
|
ExpressionAttributeNames: {
|
|
'#mb': 'material.base'
|
|
},
|
|
ExpressionAttributeValues: {
|
|
':mb': materialBase
|
|
}
|
|
};
|
|
} catch (e) {
|
|
// Fall back to old index
|
|
params = {
|
|
...params,
|
|
IndexName: 'tip-index',
|
|
KeyConditionExpression: 'tip = :tip',
|
|
ExpressionAttributeValues: {
|
|
':tip': materialBase
|
|
}
|
|
};
|
|
}
|
|
}
|
|
// Filter by storage condition
|
|
else if (storageCondition) {
|
|
params = {
|
|
...params,
|
|
FilterExpression: '#sc = :sc',
|
|
ExpressionAttributeNames: {
|
|
'#sc': 'condition.storageCondition'
|
|
},
|
|
ExpressionAttributeValues: {
|
|
':sc': storageCondition
|
|
}
|
|
};
|
|
}
|
|
|
|
// Execute query or scan
|
|
let result;
|
|
if (params.IndexName || params.KeyConditionExpression) {
|
|
result = await dynamodb.query(params).promise();
|
|
} else {
|
|
result = await dynamodb.scan(params).promise();
|
|
}
|
|
|
|
// Check if client supports new format
|
|
const acceptsNewFormat = event.headers?.['X-Accept-Format'] === 'v2';
|
|
|
|
if (acceptsNewFormat) {
|
|
// Return new format
|
|
return createResponse(200, result.Items);
|
|
} else {
|
|
// Transform to legacy format for backwards compatibility
|
|
const legacyItems = result.Items.map(transformToLegacy);
|
|
return createResponse(200, legacyItems);
|
|
}
|
|
} catch (error) {
|
|
console.error('Error getting filaments:', error);
|
|
return createResponse(500, { error: 'Failed to fetch filaments' });
|
|
}
|
|
};
|
|
|
|
// GET single filament by ID
|
|
const getFilament = async (event) => {
|
|
try {
|
|
const { id } = event.pathParameters;
|
|
|
|
const params = {
|
|
TableName: TABLE_NAME,
|
|
Key: { id }
|
|
};
|
|
|
|
const result = await dynamodb.get(params).promise();
|
|
|
|
if (!result.Item) {
|
|
return createResponse(404, { error: 'Filament not found' });
|
|
}
|
|
|
|
return createResponse(200, result.Item);
|
|
} catch (error) {
|
|
console.error('Error getting filament:', error);
|
|
return createResponse(500, { error: 'Failed to fetch filament' });
|
|
}
|
|
};
|
|
|
|
// Helper to generate SKU
|
|
const generateSKU = (brand, material, color) => {
|
|
const brandCode = brand.substring(0, 3).toUpperCase();
|
|
const materialCode = material.substring(0, 3).toUpperCase();
|
|
const colorCode = color.substring(0, 3).toUpperCase();
|
|
const random = Math.random().toString(36).substring(2, 5).toUpperCase();
|
|
return `${brandCode}-${materialCode}-${colorCode}-${random}`;
|
|
};
|
|
|
|
// POST - Create new filament
|
|
const createFilament = async (event) => {
|
|
try {
|
|
const body = JSON.parse(event.body);
|
|
const timestamp = new Date().toISOString();
|
|
const acceptsNewFormat = event.headers?.['X-Accept-Format'] === 'v2';
|
|
|
|
let item;
|
|
|
|
if (acceptsNewFormat || body.material) {
|
|
// New format
|
|
item = {
|
|
id: uuidv4(),
|
|
sku: body.sku || generateSKU(body.brand, body.material.base, body.color.name),
|
|
...body,
|
|
createdAt: timestamp,
|
|
updatedAt: timestamp
|
|
};
|
|
} else {
|
|
// Legacy format - convert to new structure
|
|
const material = {
|
|
base: body.tip || 'PLA',
|
|
modifier: body.finish !== 'Basic' ? body.finish : null
|
|
};
|
|
|
|
const storageCondition = body.vakum?.toLowerCase().includes('vakuum') ? 'vacuum' :
|
|
body.otvoreno?.toLowerCase().includes('otvorena') ? 'opened' : 'sealed';
|
|
|
|
item = {
|
|
id: uuidv4(),
|
|
sku: generateSKU(body.brand, material.base, body.boja),
|
|
brand: body.brand,
|
|
type: body.tip || 'PLA',
|
|
material: material,
|
|
color: {
|
|
name: body.boja,
|
|
hex: null
|
|
},
|
|
weight: {
|
|
value: 1000,
|
|
unit: 'g'
|
|
},
|
|
diameter: 1.75,
|
|
inventory: {
|
|
total: parseInt(body.kolicina) || 1,
|
|
available: storageCondition === 'opened' ? 0 : 1,
|
|
inUse: 0,
|
|
locations: {
|
|
vacuum: storageCondition === 'vacuum' ? 1 : 0,
|
|
opened: storageCondition === 'opened' ? 1 : 0,
|
|
printer: 0
|
|
}
|
|
},
|
|
pricing: {
|
|
purchasePrice: body.cena ? parseFloat(body.cena) : null,
|
|
currency: 'RSD'
|
|
},
|
|
condition: {
|
|
isRefill: body.refill === 'Da',
|
|
storageCondition: storageCondition
|
|
},
|
|
tags: [],
|
|
_legacy: {
|
|
tip: body.tip,
|
|
finish: body.finish,
|
|
boja: body.boja,
|
|
refill: body.refill,
|
|
vakum: body.vakum,
|
|
otvoreno: body.otvoreno,
|
|
kolicina: body.kolicina,
|
|
cena: body.cena,
|
|
status: body.status || 'new'
|
|
},
|
|
createdAt: timestamp,
|
|
updatedAt: timestamp
|
|
};
|
|
}
|
|
|
|
const params = {
|
|
TableName: TABLE_NAME,
|
|
Item: item
|
|
};
|
|
|
|
await dynamodb.put(params).promise();
|
|
|
|
// Return in appropriate format
|
|
const response = acceptsNewFormat ? item : transformToLegacy(item);
|
|
return createResponse(201, response);
|
|
} catch (error) {
|
|
console.error('Error creating filament:', error);
|
|
return createResponse(500, { error: 'Failed to create filament' });
|
|
}
|
|
};
|
|
|
|
// PUT - Update filament
|
|
const updateFilament = async (event) => {
|
|
try {
|
|
const { id } = event.pathParameters;
|
|
const body = JSON.parse(event.body);
|
|
const timestamp = new Date().toISOString();
|
|
|
|
// Determine status based on vakum and otvoreno fields
|
|
let status = 'new';
|
|
if (body.otvoreno && body.otvoreno.toLowerCase().includes('otvorena')) {
|
|
status = 'opened';
|
|
} else if (body.refill && body.refill.toLowerCase() === 'da') {
|
|
status = 'refill';
|
|
}
|
|
|
|
const params = {
|
|
TableName: TABLE_NAME,
|
|
Key: { id },
|
|
UpdateExpression: `SET
|
|
brand = :brand,
|
|
tip = :tip,
|
|
finish = :finish,
|
|
boja = :boja,
|
|
refill = :refill,
|
|
vakum = :vakum,
|
|
otvoreno = :otvoreno,
|
|
kolicina = :kolicina,
|
|
cena = :cena,
|
|
#status = :status,
|
|
updatedAt = :updatedAt`,
|
|
ExpressionAttributeNames: {
|
|
'#status': 'status'
|
|
},
|
|
ExpressionAttributeValues: {
|
|
':brand': body.brand,
|
|
':tip': body.tip,
|
|
':finish': body.finish,
|
|
':boja': body.boja,
|
|
':refill': body.refill,
|
|
':vakum': body.vakum,
|
|
':otvoreno': body.otvoreno,
|
|
':kolicina': body.kolicina,
|
|
':cena': body.cena,
|
|
':status': status,
|
|
':updatedAt': timestamp
|
|
},
|
|
ReturnValues: 'ALL_NEW'
|
|
};
|
|
|
|
const result = await dynamodb.update(params).promise();
|
|
return createResponse(200, result.Attributes);
|
|
} catch (error) {
|
|
console.error('Error updating filament:', error);
|
|
return createResponse(500, { error: 'Failed to update filament' });
|
|
}
|
|
};
|
|
|
|
// DELETE filament
|
|
const deleteFilament = async (event) => {
|
|
try {
|
|
const { id } = event.pathParameters;
|
|
|
|
const params = {
|
|
TableName: TABLE_NAME,
|
|
Key: { id }
|
|
};
|
|
|
|
await dynamodb.delete(params).promise();
|
|
return createResponse(200, { message: 'Filament deleted successfully' });
|
|
} catch (error) {
|
|
console.error('Error deleting filament:', error);
|
|
return createResponse(500, { error: 'Failed to delete filament' });
|
|
}
|
|
};
|
|
|
|
// Main handler
|
|
exports.handler = async (event) => {
|
|
const { httpMethod, resource } = event;
|
|
|
|
// Handle CORS preflight
|
|
if (httpMethod === 'OPTIONS') {
|
|
return createResponse(200, {});
|
|
}
|
|
|
|
// Route requests
|
|
if (resource === '/filaments' && httpMethod === 'GET') {
|
|
return getFilaments(event);
|
|
} else if (resource === '/filaments' && httpMethod === 'POST') {
|
|
return createFilament(event);
|
|
} else if (resource === '/filaments/{id}' && httpMethod === 'GET') {
|
|
return getFilament(event);
|
|
} else if (resource === '/filaments/{id}' && httpMethod === 'PUT') {
|
|
return updateFilament(event);
|
|
} else if (resource === '/filaments/{id}' && httpMethod === 'DELETE') {
|
|
return deleteFilament(event);
|
|
}
|
|
|
|
return createResponse(404, { error: 'Not found' });
|
|
}; |