Fix production environment variables

- Remove old Confluence variables
- Add NEXT_PUBLIC_API_URL for API access

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
DaX
2025-06-20 00:11:36 +02:00
parent 1a96e5eef6
commit a2252fa923
31 changed files with 4089 additions and 42 deletions

View File

@@ -1,5 +1,3 @@
# This file is for Amplify to know which env vars to expose to Next.js # This file is for Amplify to know which env vars to expose to Next.js
# The actual values come from Amplify Environment Variables # The actual values come from Amplify Environment Variables
CONFLUENCE_API_URL=${CONFLUENCE_API_URL} NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL}
CONFLUENCE_TOKEN=${CONFLUENCE_TOKEN}
CONFLUENCE_PAGE_ID=${CONFLUENCE_PAGE_ID}

View File

@@ -0,0 +1,386 @@
'use client'
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import axios from 'axios';
import { Filament } from '../../../src/types/filament';
interface FilamentWithId extends Filament {
id: string;
createdAt?: string;
updatedAt?: string;
}
export default function AdminDashboard() {
const router = useRouter();
const [filaments, setFilaments] = useState<FilamentWithId[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [editingFilament, setEditingFilament] = useState<FilamentWithId | null>(null);
const [showAddForm, setShowAddForm] = useState(false);
// Check authentication
useEffect(() => {
const token = localStorage.getItem('authToken');
const expiry = localStorage.getItem('tokenExpiry');
if (!token || !expiry || Date.now() > parseInt(expiry)) {
router.push('/admin');
}
}, [router]);
// Fetch filaments
const fetchFilaments = async () => {
try {
setLoading(true);
const response = await axios.get(`${process.env.NEXT_PUBLIC_API_URL}/filaments`);
setFilaments(response.data);
} catch (err) {
setError('Greška pri učitavanju filamenata');
console.error('Fetch error:', err);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchFilaments();
}, []);
const getAuthHeaders = () => ({
headers: {
Authorization: `Bearer ${localStorage.getItem('authToken')}`
}
});
const handleDelete = async (id: string) => {
if (!confirm('Da li ste sigurni da želite obrisati ovaj filament?')) return;
try {
await axios.delete(`${process.env.NEXT_PUBLIC_API_URL}/filaments/${id}`, getAuthHeaders());
await fetchFilaments();
} catch (err) {
alert('Greška pri brisanju filamenta');
console.error('Delete error:', err);
}
};
const handleSave = async (filament: Partial<FilamentWithId>) => {
try {
if (filament.id) {
// Update existing
await axios.put(
`${process.env.NEXT_PUBLIC_API_URL}/filaments/${filament.id}`,
filament,
getAuthHeaders()
);
} else {
// Create new
await axios.post(
`${process.env.NEXT_PUBLIC_API_URL}/filaments`,
filament,
getAuthHeaders()
);
}
setEditingFilament(null);
setShowAddForm(false);
await fetchFilaments();
} catch (err) {
alert('Greška pri čuvanju filamenta');
console.error('Save error:', err);
}
};
const handleLogout = () => {
localStorage.removeItem('authToken');
localStorage.removeItem('tokenExpiry');
router.push('/admin');
};
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-gray-900 dark:border-gray-100"></div>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
<header className="bg-white dark:bg-gray-800 shadow">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div className="flex justify-between items-center">
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
Admin Dashboard
</h1>
<div className="flex gap-4">
<button
onClick={() => setShowAddForm(true)}
className="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700"
>
Dodaj novi filament
</button>
<button
onClick={handleLogout}
className="px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700"
>
Odjava
</button>
</div>
</div>
</div>
</header>
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{error && (
<div className="mb-4 p-4 bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-400 rounded">
{error}
</div>
)}
{/* Add/Edit Form */}
{(showAddForm || editingFilament) && (
<FilamentForm
filament={editingFilament || {}}
onSave={handleSave}
onCancel={() => {
setEditingFilament(null);
setShowAddForm(false);
}}
/>
)}
{/* Filaments Table */}
<div className="overflow-x-auto">
<table className="min-w-full bg-white dark:bg-gray-800 shadow rounded">
<thead>
<tr className="bg-gray-100 dark:bg-gray-700">
<th className="px-4 py-2 text-left">Brand</th>
<th className="px-4 py-2 text-left">Tip</th>
<th className="px-4 py-2 text-left">Finish</th>
<th className="px-4 py-2 text-left">Boja</th>
<th className="px-4 py-2 text-left">Refill</th>
<th className="px-4 py-2 text-left">Vakum</th>
<th className="px-4 py-2 text-left">Otvoreno</th>
<th className="px-4 py-2 text-left">Količina</th>
<th className="px-4 py-2 text-left">Cena</th>
<th className="px-4 py-2 text-left">Akcije</th>
</tr>
</thead>
<tbody>
{filaments.map((filament) => (
<tr key={filament.id} className="border-t dark:border-gray-700">
<td className="px-4 py-2">{filament.brand}</td>
<td className="px-4 py-2">{filament.tip}</td>
<td className="px-4 py-2">{filament.finish}</td>
<td className="px-4 py-2">{filament.boja}</td>
<td className="px-4 py-2">{filament.refill}</td>
<td className="px-4 py-2">{filament.vakum}</td>
<td className="px-4 py-2">{filament.otvoreno}</td>
<td className="px-4 py-2">{filament.kolicina}</td>
<td className="px-4 py-2">{filament.cena}</td>
<td className="px-4 py-2">
<button
onClick={() => setEditingFilament(filament)}
className="text-blue-600 hover:text-blue-800 mr-2"
>
Izmeni
</button>
<button
onClick={() => handleDelete(filament.id)}
className="text-red-600 hover:text-red-800"
>
Obriši
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</main>
</div>
);
}
// Filament Form Component
function FilamentForm({
filament,
onSave,
onCancel
}: {
filament: Partial<FilamentWithId>,
onSave: (filament: Partial<FilamentWithId>) => void,
onCancel: () => void
}) {
const [formData, setFormData] = useState({
brand: filament.brand || '',
tip: filament.tip || '',
finish: filament.finish || '',
boja: filament.boja || '',
refill: filament.refill || '',
vakum: filament.vakum || '',
otvoreno: filament.otvoreno || '',
kolicina: filament.kolicina || '',
cena: filament.cena || '',
});
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
setFormData({
...formData,
[e.target.name]: e.target.value
});
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSave({
...filament,
...formData
});
};
return (
<div className="mb-8 p-6 bg-white dark:bg-gray-800 rounded shadow">
<h2 className="text-xl font-bold mb-4">
{filament.id ? 'Izmeni filament' : 'Dodaj novi filament'}
</h2>
<form onSubmit={handleSubmit} className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-1">Brand</label>
<input
type="text"
name="brand"
value={formData.brand}
onChange={handleChange}
required
className="w-full px-3 py-2 border rounded dark:bg-gray-700 dark:border-gray-600"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Tip</label>
<select
name="tip"
value={formData.tip}
onChange={handleChange}
required
className="w-full px-3 py-2 border rounded dark:bg-gray-700 dark:border-gray-600"
>
<option value="">Izaberi tip</option>
<option value="PLA">PLA</option>
<option value="PETG">PETG</option>
<option value="ABS">ABS</option>
<option value="TPU">TPU</option>
<option value="Silk PLA">Silk PLA</option>
<option value="PLA Matte">PLA Matte</option>
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1">Finish</label>
<select
name="finish"
value={formData.finish}
onChange={handleChange}
required
className="w-full px-3 py-2 border rounded dark:bg-gray-700 dark:border-gray-600"
>
<option value="">Izaberi finish</option>
<option value="Basic">Basic</option>
<option value="Matte">Matte</option>
<option value="Silk">Silk</option>
<option value="Metal">Metal</option>
<option value="Glow">Glow</option>
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1">Boja</label>
<input
type="text"
name="boja"
value={formData.boja}
onChange={handleChange}
required
className="w-full px-3 py-2 border rounded dark:bg-gray-700 dark:border-gray-600"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Refill</label>
<select
name="refill"
value={formData.refill}
onChange={handleChange}
className="w-full px-3 py-2 border rounded dark:bg-gray-700 dark:border-gray-600"
>
<option value="">Ne</option>
<option value="Da">Da</option>
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1">Vakum</label>
<input
type="text"
name="vakum"
value={formData.vakum}
onChange={handleChange}
className="w-full px-3 py-2 border rounded dark:bg-gray-700 dark:border-gray-600"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Otvoreno</label>
<input
type="text"
name="otvoreno"
value={formData.otvoreno}
onChange={handleChange}
className="w-full px-3 py-2 border rounded dark:bg-gray-700 dark:border-gray-600"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Količina</label>
<input
type="text"
name="kolicina"
value={formData.kolicina}
onChange={handleChange}
className="w-full px-3 py-2 border rounded dark:bg-gray-700 dark:border-gray-600"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Cena</label>
<input
type="text"
name="cena"
value={formData.cena}
onChange={handleChange}
className="w-full px-3 py-2 border rounded dark:bg-gray-700 dark:border-gray-600"
/>
</div>
<div className="md:col-span-2 flex gap-4">
<button
type="submit"
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
>
Sačuvaj
</button>
<button
type="button"
onClick={onCancel}
className="px-4 py-2 bg-gray-600 text-white rounded hover:bg-gray-700"
>
Otkaži
</button>
</div>
</form>
</div>
);
}

104
app/admin/page.tsx Normal file
View File

@@ -0,0 +1,104 @@
'use client'
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import axios from 'axios';
export default function AdminLogin() {
const router = useRouter();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setLoading(true);
try {
const response = await axios.post(`${process.env.NEXT_PUBLIC_API_URL}/auth/login`, {
username,
password
});
// Store token in localStorage
localStorage.setItem('authToken', response.data.token);
localStorage.setItem('tokenExpiry', String(Date.now() + response.data.expiresIn * 1000));
// Redirect to admin dashboard
router.push('/admin/dashboard');
} catch (err) {
setError('Neispravno korisničko ime ili lozinka');
console.error('Login error:', err);
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-md w-full space-y-8">
<div>
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900 dark:text-white">
Admin Prijava
</h2>
<p className="mt-2 text-center text-sm text-gray-600 dark:text-gray-400">
Prijavite se za upravljanje filamentima
</p>
</div>
<form className="mt-8 space-y-6" onSubmit={handleLogin}>
{error && (
<div className="rounded-md bg-red-50 dark:bg-red-900/20 p-4">
<p className="text-sm text-red-800 dark:text-red-400">{error}</p>
</div>
)}
<div className="rounded-md shadow-sm -space-y-px">
<div>
<label htmlFor="username" className="sr-only">
Korisničko ime
</label>
<input
id="username"
name="username"
type="text"
autoComplete="username"
required
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 dark:border-gray-700 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white rounded-t-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm dark:bg-gray-800"
placeholder="Korisničko ime"
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
</div>
<div>
<label htmlFor="password" className="sr-only">
Lozinka
</label>
<input
id="password"
name="password"
type="password"
autoComplete="current-password"
required
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 dark:border-gray-700 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white rounded-b-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm dark:bg-gray-800"
placeholder="Lozinka"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
</div>
<div>
<button
type="submit"
disabled={loading}
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? 'Prijavljivanje...' : 'Prijavite se'}
</button>
</div>
</form>
</div>
</div>
);
}

View File

@@ -10,31 +10,42 @@ export default function Home() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [lastUpdate, setLastUpdate] = useState<Date | null>(null); const [lastUpdate, setLastUpdate] = useState<Date | null>(null);
const [darkMode, setDarkMode] = useState(() => { const [darkMode, setDarkMode] = useState(false);
if (typeof window !== 'undefined') { const [mounted, setMounted] = useState(false);
const saved = localStorage.getItem('darkMode');
return saved ? JSON.parse(saved) : false;
}
return false;
});
// Initialize dark mode from localStorage after mounting
useEffect(() => { useEffect(() => {
if (typeof window !== 'undefined') { setMounted(true);
localStorage.setItem('darkMode', JSON.stringify(darkMode)); const saved = localStorage.getItem('darkMode');
if (darkMode) { if (saved) {
document.documentElement.classList.add('dark'); setDarkMode(JSON.parse(saved));
} else {
document.documentElement.classList.remove('dark');
}
} }
}, [darkMode]); }, []);
// Update dark mode
useEffect(() => {
if (!mounted) return;
localStorage.setItem('darkMode', JSON.stringify(darkMode));
if (darkMode) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
}, [darkMode, mounted]);
const fetchFilaments = async () => { const fetchFilaments = async () => {
try { try {
setLoading(true); setLoading(true);
setError(null); setError(null);
const response = await axios.get('/data.json'); // Use API if available, fallback to static JSON
const apiUrl = process.env.NEXT_PUBLIC_API_URL;
const url = apiUrl ? `${apiUrl}/filaments` : '/data.json';
console.log('Fetching from:', url);
console.log('API URL configured:', apiUrl);
const response = await axios.get(url);
console.log('Response data:', response.data);
setFilaments(response.data); setFilaments(response.data);
setLastUpdate(new Date()); setLastUpdate(new Date());
} catch (err) { } catch (err) {
@@ -74,13 +85,15 @@ export default function Home() {
> >
{loading ? 'Ažuriranje...' : 'Osveži'} {loading ? 'Ažuriranje...' : 'Osveži'}
</button> </button>
<button {mounted && (
onClick={() => setDarkMode(!darkMode)} <button
className="px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200 rounded hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors" onClick={() => setDarkMode(!darkMode)}
title={darkMode ? 'Svetla tema' : 'Tamna tema'} className="px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200 rounded hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors"
> title={darkMode ? 'Svetla tema' : 'Tamna tema'}
{darkMode ? '☀️' : '🌙'} >
</button> {darkMode ? '☀️' : '🌙'}
</button>
)}
</div> </div>
</div> </div>
</div> </div>

BIN
lambda/auth.zip Normal file

Binary file not shown.

110
lambda/auth/index.js Normal file
View File

@@ -0,0 +1,110 @@
const jwt = require('jsonwebtoken');
const bcrypt = require('bcryptjs');
const JWT_SECRET = process.env.JWT_SECRET;
const ADMIN_USERNAME = process.env.ADMIN_USERNAME;
const ADMIN_PASSWORD_HASH = process.env.ADMIN_PASSWORD_HASH;
// CORS headers
const headers = {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': process.env.CORS_ORIGIN || '*',
'Access-Control-Allow-Headers': 'Content-Type,Authorization',
'Access-Control-Allow-Methods': 'POST,OPTIONS'
};
// Helper function to create response
const createResponse = (statusCode, body) => ({
statusCode,
headers,
body: JSON.stringify(body)
});
// Login handler
const login = async (event) => {
try {
const { username, password } = JSON.parse(event.body);
// Validate credentials
if (username !== ADMIN_USERNAME) {
return createResponse(401, { error: 'Invalid credentials' });
}
// Compare password with hash
const isValid = await bcrypt.compare(password, ADMIN_PASSWORD_HASH);
if (!isValid) {
return createResponse(401, { error: 'Invalid credentials' });
}
// Generate JWT token
const token = jwt.sign(
{ username, role: 'admin' },
JWT_SECRET,
{ expiresIn: '24h' }
);
return createResponse(200, {
token,
expiresIn: 86400 // 24 hours in seconds
});
} catch (error) {
console.error('Login error:', error);
return createResponse(500, { error: 'Authentication failed' });
}
};
// Verify token (for Lambda authorizer)
const verifyToken = async (event) => {
try {
const token = event.authorizationToken?.replace('Bearer ', '');
if (!token) {
throw new Error('Unauthorized');
}
const decoded = jwt.verify(token, JWT_SECRET);
return {
principalId: decoded.username,
policyDocument: {
Version: '2012-10-17',
Statement: [
{
Action: 'execute-api:Invoke',
Effect: 'Allow',
Resource: event.methodArn
}
]
},
context: {
username: decoded.username,
role: decoded.role
}
};
} catch (error) {
console.error('Token verification error:', error);
throw new Error('Unauthorized');
}
};
// Main handler
exports.handler = async (event) => {
const { httpMethod, resource } = event;
// Handle CORS preflight
if (httpMethod === 'OPTIONS') {
return createResponse(200, {});
}
// Handle login
if (resource === '/auth/login' && httpMethod === 'POST') {
return login(event);
}
// Handle token verification (for Lambda authorizer)
if (event.type === 'TOKEN') {
return verifyToken(event);
}
return createResponse(404, { error: 'Not found' });
};

161
lambda/auth/package-lock.json generated Normal file
View File

@@ -0,0 +1,161 @@
{
"name": "auth-api",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "auth-api",
"version": "1.0.0",
"license": "ISC",
"dependencies": {
"bcryptjs": "^2.4.3",
"jsonwebtoken": "^9.0.2"
}
},
"node_modules/bcryptjs": {
"version": "2.4.3",
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz",
"integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==",
"license": "MIT"
},
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
},
"node_modules/ecdsa-sig-formatter": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
}
},
"node_modules/jsonwebtoken": {
"version": "9.0.2",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz",
"integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==",
"license": "MIT",
"dependencies": {
"jws": "^3.2.2",
"lodash.includes": "^4.3.0",
"lodash.isboolean": "^3.0.3",
"lodash.isinteger": "^4.0.4",
"lodash.isnumber": "^3.0.3",
"lodash.isplainobject": "^4.0.6",
"lodash.isstring": "^4.0.1",
"lodash.once": "^4.0.0",
"ms": "^2.1.1",
"semver": "^7.5.4"
},
"engines": {
"node": ">=12",
"npm": ">=6"
}
},
"node_modules/jwa": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz",
"integrity": "sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==",
"license": "MIT",
"dependencies": {
"buffer-equal-constant-time": "^1.0.1",
"ecdsa-sig-formatter": "1.0.11",
"safe-buffer": "^5.0.1"
}
},
"node_modules/jws": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz",
"integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==",
"license": "MIT",
"dependencies": {
"jwa": "^1.4.1",
"safe-buffer": "^5.0.1"
}
},
"node_modules/lodash.includes": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
"license": "MIT"
},
"node_modules/lodash.isboolean": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
"license": "MIT"
},
"node_modules/lodash.isinteger": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
"integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
"license": "MIT"
},
"node_modules/lodash.isnumber": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
"integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
"license": "MIT"
},
"node_modules/lodash.isplainobject": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
"license": "MIT"
},
"node_modules/lodash.isstring": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
"integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
"license": "MIT"
},
"node_modules/lodash.once": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
"license": "MIT"
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/semver": {
"version": "7.7.2",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
"integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
}
}
}

17
lambda/auth/package.json Normal file
View File

@@ -0,0 +1,17 @@
{
"name": "auth-api",
"version": "1.0.0",
"description": "Lambda function for authentication",
"main": "index.js",
"dependencies": {
"bcryptjs": "^2.4.3",
"jsonwebtoken": "^9.0.2"
},
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "commonjs"
}

BIN
lambda/filaments.zip Normal file

Binary file not shown.

232
lambda/filaments/index.js Normal file
View File

@@ -0,0 +1,232 @@
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',
'Access-Control-Allow-Methods': 'GET,POST,PUT,DELETE,OPTIONS'
};
// Helper function to create response
const createResponse = (statusCode, body) => ({
statusCode,
headers,
body: JSON.stringify(body)
});
// GET all filaments or filter by query params
const getFilaments = async (event) => {
try {
const queryParams = event.queryStringParameters || {};
let params = {
TableName: TABLE_NAME
};
// If filtering by brand, type, or status, use the appropriate index
if (queryParams.brand) {
params = {
...params,
IndexName: 'brand-index',
KeyConditionExpression: 'brand = :brand',
ExpressionAttributeValues: {
':brand': queryParams.brand
}
};
const result = await dynamodb.query(params).promise();
return createResponse(200, result.Items);
} else if (queryParams.tip) {
params = {
...params,
IndexName: 'tip-index',
KeyConditionExpression: 'tip = :tip',
ExpressionAttributeValues: {
':tip': queryParams.tip
}
};
const result = await dynamodb.query(params).promise();
return createResponse(200, result.Items);
} else if (queryParams.status) {
params = {
...params,
IndexName: 'status-index',
KeyConditionExpression: 'status = :status',
ExpressionAttributeValues: {
':status': queryParams.status
}
};
const result = await dynamodb.query(params).promise();
return createResponse(200, result.Items);
}
// Get all items
const result = await dynamodb.scan(params).promise();
return createResponse(200, result.Items);
} 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' });
}
};
// POST - Create new filament
const createFilament = async (event) => {
try {
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 item = {
id: uuidv4(),
...body,
status,
createdAt: timestamp,
updatedAt: timestamp
};
const params = {
TableName: TABLE_NAME,
Item: item
};
await dynamodb.put(params).promise();
return createResponse(201, item);
} 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' });
};

593
lambda/filaments/package-lock.json generated Normal file
View File

@@ -0,0 +1,593 @@
{
"name": "filaments-api",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "filaments-api",
"version": "1.0.0",
"license": "ISC",
"dependencies": {
"aws-sdk": "^2.1692.0",
"uuid": "^9.0.1"
}
},
"node_modules/available-typed-arrays": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
"integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==",
"license": "MIT",
"dependencies": {
"possible-typed-array-names": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/aws-sdk": {
"version": "2.1692.0",
"resolved": "https://registry.npmjs.org/aws-sdk/-/aws-sdk-2.1692.0.tgz",
"integrity": "sha512-x511uiJ/57FIsbgUe5csJ13k3uzu25uWQE+XqfBis/sB0SFoiElJWXRkgEAUh0U6n40eT3ay5Ue4oPkRMu1LYw==",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"buffer": "4.9.2",
"events": "1.1.1",
"ieee754": "1.1.13",
"jmespath": "0.16.0",
"querystring": "0.2.0",
"sax": "1.2.1",
"url": "0.10.3",
"util": "^0.12.4",
"uuid": "8.0.0",
"xml2js": "0.6.2"
},
"engines": {
"node": ">= 10.0.0"
}
},
"node_modules/aws-sdk/node_modules/uuid": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.0.0.tgz",
"integrity": "sha512-jOXGuXZAWdsTH7eZLtyXMqUb9EcWMGZNbL9YcGBJl4MH4nrxHmZJhEHvyLFrkxo+28uLb/NYRcStH48fnD0Vzw==",
"license": "MIT",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/buffer": {
"version": "4.9.2",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-4.9.2.tgz",
"integrity": "sha512-xq+q3SRMOxGivLhBNaUdC64hDTQwejJ+H0T/NB1XMtTVEwNTrfFF3gAxiyW0Bu/xWEGhjVKgUcMhCrUy2+uCWg==",
"license": "MIT",
"dependencies": {
"base64-js": "^1.0.2",
"ieee754": "^1.1.4",
"isarray": "^1.0.0"
}
},
"node_modules/call-bind": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz",
"integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.0",
"es-define-property": "^1.0.0",
"get-intrinsic": "^1.2.4",
"set-function-length": "^1.2.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/call-bound": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"get-intrinsic": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/define-data-property": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
"integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==",
"license": "MIT",
"dependencies": {
"es-define-property": "^1.0.0",
"es-errors": "^1.3.0",
"gopd": "^1.0.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/events": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/events/-/events-1.1.1.tgz",
"integrity": "sha512-kEcvvCBByWXGnZy6JUlgAp2gBIUjfCAV6P6TgT1/aaQKcmuAEC4OZTV1I4EWQLz2gxZw76atuVyvHhTxvi0Flw==",
"license": "MIT",
"engines": {
"node": ">=0.4.x"
}
},
"node_modules/for-each": {
"version": "0.3.5",
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
"integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==",
"license": "MIT",
"dependencies": {
"is-callable": "^1.2.7"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-property-descriptors": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
"integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
"license": "MIT",
"dependencies": {
"es-define-property": "^1.0.0"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-tostringtag": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/ieee754": {
"version": "1.1.13",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.1.13.tgz",
"integrity": "sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg==",
"license": "BSD-3-Clause"
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/is-arguments": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz",
"integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"has-tostringtag": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-callable": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz",
"integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-generator-function": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.0.tgz",
"integrity": "sha512-nPUB5km40q9e8UfN/Zc24eLlzdSf9OfKByBw9CIdw4H1giPMeA0OIJvbchsCu4npfI2QcMVBsGEBHKZ7wLTWmQ==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.3",
"get-proto": "^1.0.0",
"has-tostringtag": "^1.0.2",
"safe-regex-test": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-regex": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
"integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"gopd": "^1.2.0",
"has-tostringtag": "^1.0.2",
"hasown": "^2.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-typed-array": {
"version": "1.1.15",
"resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz",
"integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==",
"license": "MIT",
"dependencies": {
"which-typed-array": "^1.1.16"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"license": "MIT"
},
"node_modules/jmespath": {
"version": "0.16.0",
"resolved": "https://registry.npmjs.org/jmespath/-/jmespath-0.16.0.tgz",
"integrity": "sha512-9FzQjJ7MATs1tSpnco1K6ayiYE3figslrXA72G2HQ/n76RzvYlofyi5QM+iX4YRs/pu3yzxlVQSST23+dMDknw==",
"license": "Apache-2.0",
"engines": {
"node": ">= 0.6.0"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/possible-typed-array-names": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
"integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/punycode": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-1.3.2.tgz",
"integrity": "sha512-RofWgt/7fL5wP1Y7fxE7/EmTLzQVnB0ycyibJ0OOHIlJqTNzglYFxVwETOcIoJqJmpDXJ9xImDv+Fq34F/d4Dw==",
"license": "MIT"
},
"node_modules/querystring": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/querystring/-/querystring-0.2.0.tgz",
"integrity": "sha512-X/xY82scca2tau62i9mDyU9K+I+djTMUsvwf7xnUX5GLvVzgJybOJf4Y6o9Zx3oJK/LSXg5tTZBjwzqVPaPO2g==",
"deprecated": "The querystring API is considered Legacy. new code should use the URLSearchParams API instead.",
"engines": {
"node": ">=0.4.x"
}
},
"node_modules/safe-regex-test": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz",
"integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"is-regex": "^1.2.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/sax": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/sax/-/sax-1.2.1.tgz",
"integrity": "sha512-8I2a3LovHTOpm7NV5yOyO8IHqgVsfK4+UuySrXU8YXkSRX7k6hCV9b3HrkKCr3nMpgj+0bmocaJJWpvp1oc7ZA==",
"license": "ISC"
},
"node_modules/set-function-length": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
"integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==",
"license": "MIT",
"dependencies": {
"define-data-property": "^1.1.4",
"es-errors": "^1.3.0",
"function-bind": "^1.1.2",
"get-intrinsic": "^1.2.4",
"gopd": "^1.0.1",
"has-property-descriptors": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/url": {
"version": "0.10.3",
"resolved": "https://registry.npmjs.org/url/-/url-0.10.3.tgz",
"integrity": "sha512-hzSUW2q06EqL1gKM/a+obYHLIO6ct2hwPuviqTTOcfFVc61UbfJ2Q32+uGL/HCPxKqrdGB5QUwIe7UqlDgwsOQ==",
"license": "MIT",
"dependencies": {
"punycode": "1.3.2",
"querystring": "0.2.0"
}
},
"node_modules/util": {
"version": "0.12.5",
"resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz",
"integrity": "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"is-arguments": "^1.0.4",
"is-generator-function": "^1.0.7",
"is-typed-array": "^1.1.3",
"which-typed-array": "^1.1.2"
}
},
"node_modules/uuid": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",
"integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/which-typed-array": {
"version": "1.1.19",
"resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz",
"integrity": "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==",
"license": "MIT",
"dependencies": {
"available-typed-arrays": "^1.0.7",
"call-bind": "^1.0.8",
"call-bound": "^1.0.4",
"for-each": "^0.3.5",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-tostringtag": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/xml2js": {
"version": "0.6.2",
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz",
"integrity": "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==",
"license": "MIT",
"dependencies": {
"sax": ">=0.6.0",
"xmlbuilder": "~11.0.0"
},
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/xmlbuilder": {
"version": "11.0.1",
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz",
"integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==",
"license": "MIT",
"engines": {
"node": ">=4.0"
}
}
}
}

View File

@@ -0,0 +1,17 @@
{
"name": "filaments-api",
"version": "1.0.0",
"description": "Lambda function for filaments CRUD operations",
"main": "index.js",
"dependencies": {
"aws-sdk": "^2.1692.0",
"uuid": "^9.0.1"
},
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "commonjs"
}

10
package-lock.json generated
View File

@@ -10,6 +10,7 @@
"dependencies": { "dependencies": {
"@types/cheerio": "^0.22.35", "@types/cheerio": "^0.22.35",
"axios": "^1.6.2", "axios": "^1.6.2",
"bcryptjs": "^3.0.2",
"cheerio": "^1.1.0", "cheerio": "^1.1.0",
"next": "^15.3.4", "next": "^15.3.4",
"react": "^19.1.0", "react": "^19.1.0",
@@ -4214,6 +4215,15 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/bcryptjs": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.2.tgz",
"integrity": "sha512-k38b3XOZKv60C4E2hVsXTolJWfkGRMbILBIe2IBITXciy5bOsTKot5kDrf3ZfufQtQOUN5mXceUEpU1rTl9Uog==",
"license": "BSD-3-Clause",
"bin": {
"bcrypt": "bin/bcrypt"
}
},
"node_modules/binary-extensions": { "node_modules/binary-extensions": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",

View File

@@ -11,11 +11,14 @@
"test:watch": "jest --watch", "test:watch": "jest --watch",
"security:check": "node scripts/security-check.js", "security:check": "node scripts/security-check.js",
"test:build": "node scripts/test-build.js", "test:build": "node scripts/test-build.js",
"prepare": "husky" "prepare": "husky",
"migrate": "cd scripts && npm install && npm run migrate",
"migrate:clear": "cd scripts && npm install && npm run migrate:clear"
}, },
"dependencies": { "dependencies": {
"@types/cheerio": "^0.22.35", "@types/cheerio": "^0.22.35",
"axios": "^1.6.2", "axios": "^1.6.2",
"bcryptjs": "^3.0.2",
"cheerio": "^1.1.0", "cheerio": "^1.1.0",
"next": "^15.3.4", "next": "^15.3.4",
"react": "^19.1.0", "react": "^19.1.0",

167
public/data.json Normal file
View File

@@ -0,0 +1,167 @@
[
{
"brand": "Bambu Lab",
"tip": "PLA",
"finish": "Basic",
"boja": "Lavender Purple",
"refill": "",
"vakum": "u vakuumu",
"otvoreno": "",
"kolicina": "1kg",
"cena": "2500"
},
{
"brand": "Bambu Lab",
"tip": "PLA",
"finish": "Matte",
"boja": "Charcoal Black",
"refill": "",
"vakum": "",
"otvoreno": "otvorena",
"kolicina": "0.8kg",
"cena": "2800"
},
{
"brand": "Bambu Lab",
"tip": "PETG",
"finish": "Basic",
"boja": "Transparent",
"refill": "Da",
"vakum": "u vakuumu",
"otvoreno": "",
"kolicina": "1kg",
"cena": "3200"
},
{
"brand": "Azure Film",
"tip": "PLA",
"finish": "Basic",
"boja": "White",
"refill": "",
"vakum": "u vakuumu",
"otvoreno": "",
"kolicina": "1kg",
"cena": "2200"
},
{
"brand": "Azure Film",
"tip": "PETG",
"finish": "Basic",
"boja": "Orange",
"refill": "",
"vakum": "",
"otvoreno": "otvorena",
"kolicina": "0.5kg",
"cena": "2600"
},
{
"brand": "Bambu Lab",
"tip": "Silk PLA",
"finish": "Silk",
"boja": "Gold",
"refill": "Da",
"vakum": "",
"otvoreno": "",
"kolicina": "0.5kg",
"cena": "3500"
},
{
"brand": "Bambu Lab",
"tip": "PLA Matte",
"finish": "Matte",
"boja": "Forest Green",
"refill": "",
"vakum": "u vakuumu",
"otvoreno": "",
"kolicina": "1kg",
"cena": "2800"
},
{
"brand": "PanaChroma",
"tip": "PLA",
"finish": "Basic",
"boja": "Red",
"refill": "",
"vakum": "u vakuumu",
"otvoreno": "",
"kolicina": "1kg",
"cena": "2300"
},
{
"brand": "PanaChroma",
"tip": "PETG",
"finish": "Basic",
"boja": "Blue",
"refill": "",
"vakum": "",
"otvoreno": "otvorena",
"kolicina": "0.75kg",
"cena": "2700"
},
{
"brand": "Fiberlogy",
"tip": "PLA",
"finish": "Basic",
"boja": "Gray",
"refill": "",
"vakum": "u vakuumu",
"otvoreno": "",
"kolicina": "0.85kg",
"cena": "2400"
},
{
"brand": "Fiberlogy",
"tip": "ABS",
"finish": "Basic",
"boja": "Black",
"refill": "",
"vakum": "",
"otvoreno": "",
"kolicina": "1kg",
"cena": "2900"
},
{
"brand": "Fiberlogy",
"tip": "TPU",
"finish": "Basic",
"boja": "Lime Green",
"refill": "",
"vakum": "",
"otvoreno": "otvorena",
"kolicina": "0.3kg",
"cena": "4500"
},
{
"brand": "Azure Film",
"tip": "PLA",
"finish": "Silk",
"boja": "Silver",
"refill": "Da",
"vakum": "u vakuumu",
"otvoreno": "",
"kolicina": "1kg",
"cena": "2800"
},
{
"brand": "Bambu Lab",
"tip": "PLA",
"finish": "Basic",
"boja": "Jade White",
"refill": "",
"vakum": "",
"otvoreno": "otvorena",
"kolicina": "0.5kg",
"cena": "2500"
},
{
"brand": "PanaChroma",
"tip": "Silk PLA",
"finish": "Silk",
"boja": "Copper",
"refill": "",
"vakum": "u vakuumu",
"otvoreno": "",
"kolicina": "1kg",
"cena": "3200"
}
]

84
scripts/README.md Normal file
View File

@@ -0,0 +1,84 @@
# Data Migration Scripts
This directory contains scripts for migrating filament data from Confluence to DynamoDB.
## Prerequisites
1. AWS credentials configured (either via AWS CLI or environment variables)
2. DynamoDB table created via Terraform
3. Confluence API credentials (if migrating from Confluence)
## Setup
```bash
cd scripts
npm install
```
## Configuration
Create a `.env.local` file in the project root with:
```env
# AWS Configuration
AWS_REGION=eu-central-1
DYNAMODB_TABLE_NAME=filamenteka-filaments
# Confluence Configuration (optional)
CONFLUENCE_API_URL=https://your-domain.atlassian.net
CONFLUENCE_TOKEN=your-email:your-api-token
CONFLUENCE_PAGE_ID=your-page-id
```
## Usage
### Migrate from local data (data.json)
```bash
npm run migrate
```
### Clear existing data and migrate
```bash
npm run migrate:clear
```
### Manual execution
```bash
# Migrate without clearing
node migrate-with-parser.js
# Clear existing data first
node migrate-with-parser.js --clear
```
## What the script does
1. **Checks for Confluence credentials**
- If found: Fetches data from Confluence page
- If not found: Uses local `public/data.json` file
2. **Parses the data**
- Extracts filament information from HTML table (Confluence)
- Or reads JSON directly (local file)
3. **Prepares data for DynamoDB**
- Generates unique IDs for each filament
- Adds timestamps (createdAt, updatedAt)
4. **Writes to DynamoDB**
- Writes in batches of 25 items (DynamoDB limit)
- Shows progress during migration
5. **Verifies the migration**
- Counts total items in DynamoDB
- Shows a sample item for verification
## Troubleshooting
- **Table not found**: Make sure you've run `terraform apply` first
- **Access denied**: Check your AWS credentials and permissions
- **Confluence errors**: Verify your API token and page ID
- **Empty migration**: Check that the Confluence page has a table with the expected format

View File

@@ -0,0 +1,13 @@
const bcrypt = require('bcryptjs');
const password = process.argv[2];
if (!password) {
console.error('Usage: node generate-password-hash.js <password>');
process.exit(1);
}
const hash = bcrypt.hashSync(password, 10);
console.log('Password hash:', hash);
console.log('\nAdd this to your terraform.tfvars:');
console.log(`admin_password_hash = "${hash}"`);

View File

@@ -0,0 +1,194 @@
#!/usr/bin/env node
require('dotenv').config({ path: '.env.local' });
const axios = require('axios');
const AWS = require('aws-sdk');
const { v4: uuidv4 } = require('uuid');
// Configure AWS
AWS.config.update({
region: process.env.AWS_REGION || 'eu-central-1'
});
const dynamodb = new AWS.DynamoDB.DocumentClient();
const TABLE_NAME = process.env.DYNAMODB_TABLE_NAME || 'filamenteka-filaments';
// Confluence configuration
const CONFLUENCE_API_URL = process.env.CONFLUENCE_API_URL;
const CONFLUENCE_TOKEN = process.env.CONFLUENCE_TOKEN;
const CONFLUENCE_PAGE_ID = process.env.CONFLUENCE_PAGE_ID;
async function fetchConfluenceData() {
try {
console.log('Fetching data from Confluence...');
const response = await axios.get(
`${CONFLUENCE_API_URL}/wiki/rest/api/content/${CONFLUENCE_PAGE_ID}?expand=body.storage`,
{
headers: {
'Authorization': `Basic ${Buffer.from(CONFLUENCE_TOKEN).toString('base64')}`,
'Accept': 'application/json'
}
}
);
const htmlContent = response.data.body.storage.value;
return parseConfluenceTable(htmlContent);
} catch (error) {
console.error('Error fetching from Confluence:', error.message);
throw error;
}
}
function parseConfluenceTable(html) {
// Simple HTML table parser - in production, use a proper HTML parser like cheerio
const rows = [];
const tableRegex = /<tr[^>]*>(.*?)<\/tr>/gs;
const cellRegex = /<t[dh][^>]*>(.*?)<\/t[dh]>/gs;
let match;
let isHeader = true;
while ((match = tableRegex.exec(html)) !== null) {
const rowHtml = match[1];
const cells = [];
let cellMatch;
while ((cellMatch = cellRegex.exec(rowHtml)) !== null) {
// Remove HTML tags from cell content
const cellContent = cellMatch[1]
.replace(/<[^>]*>/g, '')
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.trim();
cells.push(cellContent);
}
if (!isHeader && cells.length > 0) {
rows.push(cells);
}
isHeader = false;
}
// Map rows to filament objects
return rows.map(row => ({
brand: row[0] || '',
tip: row[1] || '',
finish: row[2] || '',
boja: row[3] || '',
refill: row[4] || '',
vakum: row[5] || '',
otvoreno: row[6] || '',
kolicina: row[7] || '',
cena: row[8] || ''
}));
}
async function migrateToLocalJSON() {
try {
console.log('Migrating to local JSON file for testing...');
// For now, use the mock data we created
const fs = require('fs');
const data = JSON.parse(fs.readFileSync('./public/data.json', 'utf8'));
const filaments = data.map(item => ({
id: uuidv4(),
...item,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
}));
console.log(`Found ${filaments.length} filaments to migrate`);
return filaments;
} catch (error) {
console.error('Error reading local data:', error);
throw error;
}
}
async function migrateToDynamoDB(filaments) {
console.log(`Migrating ${filaments.length} filaments to DynamoDB...`);
// Check if table exists
try {
const dynamo = new AWS.DynamoDB();
await dynamo.describeTable({ TableName: TABLE_NAME }).promise();
console.log(`Table ${TABLE_NAME} exists`);
} catch (error) {
if (error.code === 'ResourceNotFoundException') {
console.error(`Table ${TABLE_NAME} does not exist. Please run Terraform first.`);
process.exit(1);
}
throw error;
}
// Batch write items
const chunks = [];
for (let i = 0; i < filaments.length; i += 25) {
chunks.push(filaments.slice(i, i + 25));
}
for (const chunk of chunks) {
const params = {
RequestItems: {
[TABLE_NAME]: chunk.map(item => ({
PutRequest: { Item: item }
}))
}
};
try {
await dynamodb.batchWrite(params).promise();
console.log(`Migrated ${chunk.length} items`);
} catch (error) {
console.error('Error writing batch:', error);
throw error;
}
}
console.log('Migration completed successfully!');
}
async function main() {
try {
let filaments;
if (CONFLUENCE_API_URL && CONFLUENCE_TOKEN && CONFLUENCE_PAGE_ID) {
// Fetch from Confluence
const confluenceData = await fetchConfluenceData();
filaments = confluenceData.map(item => ({
id: uuidv4(),
...item,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
}));
} else {
console.log('Confluence credentials not found, using local data...');
filaments = await migrateToLocalJSON();
}
// Migrate to DynamoDB
await migrateToDynamoDB(filaments);
// Verify migration
const params = {
TableName: TABLE_NAME,
Select: 'COUNT'
};
const result = await dynamodb.scan(params).promise();
console.log(`\nVerification: ${result.Count} items in DynamoDB`);
} catch (error) {
console.error('Migration failed:', error);
process.exit(1);
}
}
// Run migration
if (require.main === module) {
main();
}

View File

@@ -0,0 +1,241 @@
#!/usr/bin/env node
require('dotenv').config({ path: '.env.local' });
const axios = require('axios');
const AWS = require('aws-sdk');
const { v4: uuidv4 } = require('uuid');
const cheerio = require('cheerio');
// Configure AWS
AWS.config.update({
region: process.env.AWS_REGION || 'eu-central-1'
});
const dynamodb = new AWS.DynamoDB.DocumentClient();
const TABLE_NAME = process.env.DYNAMODB_TABLE_NAME || 'filamenteka-filaments';
// Confluence configuration
const CONFLUENCE_API_URL = process.env.CONFLUENCE_API_URL;
const CONFLUENCE_TOKEN = process.env.CONFLUENCE_TOKEN;
const CONFLUENCE_PAGE_ID = process.env.CONFLUENCE_PAGE_ID;
async function fetchConfluenceData() {
try {
console.log('Fetching data from Confluence...');
const response = await axios.get(
`${CONFLUENCE_API_URL}/wiki/rest/api/content/${CONFLUENCE_PAGE_ID}?expand=body.storage`,
{
headers: {
'Authorization': `Basic ${Buffer.from(CONFLUENCE_TOKEN).toString('base64')}`,
'Accept': 'application/json'
}
}
);
const htmlContent = response.data.body.storage.value;
return parseConfluenceTable(htmlContent);
} catch (error) {
console.error('Error fetching from Confluence:', error.message);
throw error;
}
}
function parseConfluenceTable(html) {
const $ = cheerio.load(html);
const filaments = [];
// Find the table and iterate through rows
$('table').find('tr').each((index, row) => {
// Skip header row
if (index === 0) return;
const cells = $(row).find('td');
if (cells.length >= 9) {
const filament = {
brand: $(cells[0]).text().trim(),
tip: $(cells[1]).text().trim(),
finish: $(cells[2]).text().trim(),
boja: $(cells[3]).text().trim(),
refill: $(cells[4]).text().trim(),
vakum: $(cells[5]).text().trim(),
otvoreno: $(cells[6]).text().trim(),
kolicina: $(cells[7]).text().trim(),
cena: $(cells[8]).text().trim()
};
// Only add if row has valid data
if (filament.brand || filament.boja) {
filaments.push(filament);
}
}
});
return filaments;
}
async function clearDynamoTable() {
console.log('Clearing existing data from DynamoDB...');
// Scan all items
const scanParams = {
TableName: TABLE_NAME,
ProjectionExpression: 'id'
};
try {
const scanResult = await dynamodb.scan(scanParams).promise();
if (scanResult.Items.length === 0) {
console.log('Table is already empty');
return;
}
// Delete in batches
const deleteRequests = scanResult.Items.map(item => ({
DeleteRequest: { Key: { id: item.id } }
}));
// DynamoDB batchWrite supports max 25 items
for (let i = 0; i < deleteRequests.length; i += 25) {
const batch = deleteRequests.slice(i, i + 25);
const params = {
RequestItems: {
[TABLE_NAME]: batch
}
};
await dynamodb.batchWrite(params).promise();
console.log(`Deleted ${batch.length} items`);
}
console.log('Table cleared successfully');
} catch (error) {
console.error('Error clearing table:', error);
throw error;
}
}
async function migrateToDynamoDB(filaments) {
console.log(`Migrating ${filaments.length} filaments to DynamoDB...`);
// Check if table exists
try {
const dynamo = new AWS.DynamoDB();
await dynamo.describeTable({ TableName: TABLE_NAME }).promise();
console.log(`Table ${TABLE_NAME} exists`);
} catch (error) {
if (error.code === 'ResourceNotFoundException') {
console.error(`Table ${TABLE_NAME} does not exist. Please run Terraform first.`);
process.exit(1);
}
throw error;
}
// Add IDs and timestamps
const itemsToInsert = filaments.map(item => ({
id: uuidv4(),
...item,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
}));
// Batch write items (max 25 per batch)
const chunks = [];
for (let i = 0; i < itemsToInsert.length; i += 25) {
chunks.push(itemsToInsert.slice(i, i + 25));
}
let totalMigrated = 0;
for (const chunk of chunks) {
const params = {
RequestItems: {
[TABLE_NAME]: chunk.map(item => ({
PutRequest: { Item: item }
}))
}
};
try {
await dynamodb.batchWrite(params).promise();
totalMigrated += chunk.length;
console.log(`Migrated ${totalMigrated}/${itemsToInsert.length} items`);
} catch (error) {
console.error('Error writing batch:', error);
throw error;
}
}
console.log('Migration completed successfully!');
return totalMigrated;
}
async function main() {
try {
let filaments;
// Check for --clear flag
const shouldClear = process.argv.includes('--clear');
if (shouldClear) {
await clearDynamoTable();
}
if (CONFLUENCE_API_URL && CONFLUENCE_TOKEN && CONFLUENCE_PAGE_ID) {
// Fetch from Confluence
console.log('Using Confluence as data source');
filaments = await fetchConfluenceData();
} else {
console.log('Confluence credentials not found, using local mock data...');
const fs = require('fs');
const data = JSON.parse(fs.readFileSync('../public/data.json', 'utf8'));
filaments = data;
}
console.log(`Found ${filaments.length} filaments to migrate`);
// Show sample data
if (filaments.length > 0) {
console.log('\nSample data:');
console.log(JSON.stringify(filaments[0], null, 2));
}
// Migrate to DynamoDB
const migrated = await migrateToDynamoDB(filaments);
// Verify migration
const params = {
TableName: TABLE_NAME,
Select: 'COUNT'
};
const result = await dynamodb.scan(params).promise();
console.log(`\nVerification: ${result.Count} total items now in DynamoDB`);
// Show sample from DynamoDB
const sampleParams = {
TableName: TABLE_NAME,
Limit: 1
};
const sampleResult = await dynamodb.scan(sampleParams).promise();
if (sampleResult.Items.length > 0) {
console.log('\nSample from DynamoDB:');
console.log(JSON.stringify(sampleResult.Items[0], null, 2));
}
} catch (error) {
console.error('Migration failed:', error);
process.exit(1);
}
}
// Run migration
if (require.main === module) {
console.log('Confluence to DynamoDB Migration Tool');
console.log('=====================================');
console.log('Usage: node migrate-with-parser.js [--clear]');
console.log(' --clear: Clear existing data before migration\n');
main();
}

1019
scripts/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

16
scripts/package.json Normal file
View File

@@ -0,0 +1,16 @@
{
"name": "filamenteka-scripts",
"version": "1.0.0",
"description": "Migration and utility scripts for Filamenteka",
"scripts": {
"migrate": "node migrate-with-parser.js",
"migrate:clear": "node migrate-with-parser.js --clear"
},
"dependencies": {
"aws-sdk": "^2.1472.0",
"axios": "^1.6.2",
"cheerio": "^1.0.0-rc.12",
"dotenv": "^16.3.1",
"uuid": "^9.0.1"
}
}

View File

@@ -2,6 +2,7 @@ import React, { useState, useMemo } from 'react';
import { Filament } from '../types/filament'; import { Filament } from '../types/filament';
import { ColorCell } from './ColorCell'; import { ColorCell } from './ColorCell';
import { getFilamentColor, getColorStyle, getContrastColor } from '../data/bambuLabColors'; import { getFilamentColor, getColorStyle, getContrastColor } from '../data/bambuLabColors';
import '../styles/select.css';
interface FilamentTableProps { interface FilamentTableProps {
filaments: Filament[]; filaments: Filament[];
@@ -14,12 +15,47 @@ export const FilamentTable: React.FC<FilamentTableProps> = ({ filaments, loading
const [sortField, setSortField] = useState<keyof Filament>('boja'); const [sortField, setSortField] = useState<keyof Filament>('boja');
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc'); const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc');
// Filter states
const [filterBrand, setFilterBrand] = useState('');
const [filterTip, setFilterTip] = useState('');
const [filterFinish, setFilterFinish] = useState('');
const [filterStatus, setFilterStatus] = useState('');
// Get unique values for filters
const uniqueBrands = useMemo(() => [...new Set(filaments.map(f => f.brand))].sort(), [filaments]);
const uniqueTips = useMemo(() => [...new Set(filaments.map(f => f.tip))].sort(), [filaments]);
const uniqueFinishes = useMemo(() => [...new Set(filaments.map(f => f.finish))].sort(), [filaments]);
const filteredAndSortedFilaments = useMemo(() => { const filteredAndSortedFilaments = useMemo(() => {
let filtered = filaments.filter(filament => let filtered = filaments.filter(filament => {
Object.values(filament).some(value => // Search filter
const matchesSearch = Object.values(filament).some(value =>
value.toLowerCase().includes(searchTerm.toLowerCase()) value.toLowerCase().includes(searchTerm.toLowerCase())
) );
);
// Brand filter
const matchesBrand = !filterBrand || filament.brand === filterBrand;
// Type filter
const matchesTip = !filterTip || filament.tip === filterTip;
// Finish filter
const matchesFinish = !filterFinish || filament.finish === filterFinish;
// Status filter
let matchesStatus = true;
if (filterStatus) {
if (filterStatus === 'new') {
matchesStatus = filament.vakum.toLowerCase().includes('vakuum') && !filament.otvoreno;
} else if (filterStatus === 'opened') {
matchesStatus = filament.otvoreno.toLowerCase().includes('otvorena');
} else if (filterStatus === 'refill') {
matchesStatus = filament.refill.toLowerCase() === 'da';
}
}
return matchesSearch && matchesBrand && matchesTip && matchesFinish && matchesStatus;
});
filtered.sort((a, b) => { filtered.sort((a, b) => {
const aValue = a[sortField]; const aValue = a[sortField];
@@ -33,7 +69,7 @@ export const FilamentTable: React.FC<FilamentTableProps> = ({ filaments, loading
}); });
return filtered; return filtered;
}, [filaments, searchTerm, sortField, sortOrder]); }, [filaments, searchTerm, sortField, sortOrder, filterBrand, filterTip, filterFinish, filterStatus]);
const handleSort = (field: keyof Filament) => { const handleSort = (field: keyof Filament) => {
if (sortField === field) { if (sortField === field) {
@@ -62,14 +98,103 @@ export const FilamentTable: React.FC<FilamentTableProps> = ({ filaments, loading
return ( return (
<div className="w-full"> <div className="w-full">
<div className="mb-4"> <div className="space-y-4 mb-4">
<input {/* Search Bar */}
type="text" <div>
placeholder="Pretraži filamente..." <input
value={searchTerm} type="text"
onChange={(e) => setSearchTerm(e.target.value)} placeholder="Pretraži filamente..."
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" value={searchTerm}
/> onChange={(e) => setSearchTerm(e.target.value)}
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
{/* Filter Row */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{/* Brand Filter */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Brend
</label>
<select
value={filterBrand}
onChange={(e) => setFilterBrand(e.target.value)}
className="custom-select w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="">Svi brendovi</option>
{uniqueBrands.map(brand => (
<option key={brand} value={brand}>{brand}</option>
))}
</select>
</div>
{/* Type Filter */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Tip
</label>
<select
value={filterTip}
onChange={(e) => setFilterTip(e.target.value)}
className="custom-select w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="">Svi tipovi</option>
{uniqueTips.map(tip => (
<option key={tip} value={tip}>{tip}</option>
))}
</select>
</div>
{/* Finish Filter */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Završna obrada
</label>
<select
value={filterFinish}
onChange={(e) => setFilterFinish(e.target.value)}
className="custom-select w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="">Sve obrade</option>
{uniqueFinishes.map(finish => (
<option key={finish} value={finish}>{finish}</option>
))}
</select>
</div>
{/* Status Filter */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Status
</label>
<select
value={filterStatus}
onChange={(e) => setFilterStatus(e.target.value)}
className="custom-select w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="">Svi statusi</option>
<option value="new">Novi (vakuum)</option>
<option value="opened">Otvoreni</option>
<option value="refill">Refill</option>
</select>
</div>
</div>
{/* Clear Filters Button */}
{(filterBrand || filterTip || filterFinish || filterStatus) && (
<button
onClick={() => {
setFilterBrand('');
setFilterTip('');
setFilterFinish('');
setFilterStatus('');
}}
className="text-sm text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300"
>
Obriši filtere
</button>
)}
</div> </div>
<div className="overflow-x-auto"> <div className="overflow-x-auto">

24
src/styles/select.css Normal file
View File

@@ -0,0 +1,24 @@
/* Custom select styling for cross-browser consistency */
.custom-select {
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e");
background-repeat: no-repeat;
background-position: right 0.5rem center;
background-size: 1.5em 1.5em;
padding-right: 2.5rem;
}
.dark .custom-select {
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%239ca3af' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e");
}
/* Safari-specific fixes */
@media not all and (min-resolution:.001dpcm) {
@supports (-webkit-appearance:none) {
.custom-select {
padding-right: 2.5rem;
}
}
}

281
terraform/api_gateway.tf Normal file
View File

@@ -0,0 +1,281 @@
# API Gateway REST API
resource "aws_api_gateway_rest_api" "api" {
name = "${var.app_name}-api"
description = "API for ${var.app_name}"
}
# API Gateway Resources
resource "aws_api_gateway_resource" "filaments" {
rest_api_id = aws_api_gateway_rest_api.api.id
parent_id = aws_api_gateway_rest_api.api.root_resource_id
path_part = "filaments"
}
resource "aws_api_gateway_resource" "filament" {
rest_api_id = aws_api_gateway_rest_api.api.id
parent_id = aws_api_gateway_resource.filaments.id
path_part = "{id}"
}
resource "aws_api_gateway_resource" "auth" {
rest_api_id = aws_api_gateway_rest_api.api.id
parent_id = aws_api_gateway_rest_api.api.root_resource_id
path_part = "auth"
}
resource "aws_api_gateway_resource" "login" {
rest_api_id = aws_api_gateway_rest_api.api.id
parent_id = aws_api_gateway_resource.auth.id
path_part = "login"
}
# Lambda Authorizer
resource "aws_api_gateway_authorizer" "jwt_authorizer" {
name = "${var.app_name}-jwt-authorizer"
rest_api_id = aws_api_gateway_rest_api.api.id
type = "TOKEN"
authorizer_uri = aws_lambda_function.auth_api.invoke_arn
authorizer_credentials = aws_iam_role.api_gateway_auth_invocation.arn
identity_source = "method.request.header.Authorization"
}
# IAM role for API Gateway to invoke Lambda authorizer
resource "aws_iam_role" "api_gateway_auth_invocation" {
name = "${var.app_name}-api-gateway-auth-invocation"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = {
Service = "apigateway.amazonaws.com"
}
}
]
})
}
resource "aws_iam_role_policy" "api_gateway_auth_invocation" {
name = "${var.app_name}-api-gateway-auth-invocation"
role = aws_iam_role.api_gateway_auth_invocation.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = "lambda:InvokeFunction"
Resource = aws_lambda_function.auth_api.arn
}
]
})
}
# Methods for /filaments
resource "aws_api_gateway_method" "get_filaments" {
rest_api_id = aws_api_gateway_rest_api.api.id
resource_id = aws_api_gateway_resource.filaments.id
http_method = "GET"
authorization = "NONE"
}
resource "aws_api_gateway_method" "post_filament" {
rest_api_id = aws_api_gateway_rest_api.api.id
resource_id = aws_api_gateway_resource.filaments.id
http_method = "POST"
authorization = "CUSTOM"
authorizer_id = aws_api_gateway_authorizer.jwt_authorizer.id
}
# Methods for /filaments/{id}
resource "aws_api_gateway_method" "get_filament" {
rest_api_id = aws_api_gateway_rest_api.api.id
resource_id = aws_api_gateway_resource.filament.id
http_method = "GET"
authorization = "NONE"
}
resource "aws_api_gateway_method" "put_filament" {
rest_api_id = aws_api_gateway_rest_api.api.id
resource_id = aws_api_gateway_resource.filament.id
http_method = "PUT"
authorization = "CUSTOM"
authorizer_id = aws_api_gateway_authorizer.jwt_authorizer.id
}
resource "aws_api_gateway_method" "delete_filament" {
rest_api_id = aws_api_gateway_rest_api.api.id
resource_id = aws_api_gateway_resource.filament.id
http_method = "DELETE"
authorization = "CUSTOM"
authorizer_id = aws_api_gateway_authorizer.jwt_authorizer.id
}
# Method for /auth/login
resource "aws_api_gateway_method" "login" {
rest_api_id = aws_api_gateway_rest_api.api.id
resource_id = aws_api_gateway_resource.login.id
http_method = "POST"
authorization = "NONE"
}
# OPTIONS methods for CORS
resource "aws_api_gateway_method" "options_filaments" {
rest_api_id = aws_api_gateway_rest_api.api.id
resource_id = aws_api_gateway_resource.filaments.id
http_method = "OPTIONS"
authorization = "NONE"
}
resource "aws_api_gateway_method" "options_filament" {
rest_api_id = aws_api_gateway_rest_api.api.id
resource_id = aws_api_gateway_resource.filament.id
http_method = "OPTIONS"
authorization = "NONE"
}
resource "aws_api_gateway_method" "options_login" {
rest_api_id = aws_api_gateway_rest_api.api.id
resource_id = aws_api_gateway_resource.login.id
http_method = "OPTIONS"
authorization = "NONE"
}
# Lambda integrations
resource "aws_api_gateway_integration" "filaments_get" {
rest_api_id = aws_api_gateway_rest_api.api.id
resource_id = aws_api_gateway_resource.filaments.id
http_method = aws_api_gateway_method.get_filaments.http_method
integration_http_method = "POST"
type = "AWS_PROXY"
uri = aws_lambda_function.filaments_api.invoke_arn
}
resource "aws_api_gateway_integration" "filaments_post" {
rest_api_id = aws_api_gateway_rest_api.api.id
resource_id = aws_api_gateway_resource.filaments.id
http_method = aws_api_gateway_method.post_filament.http_method
integration_http_method = "POST"
type = "AWS_PROXY"
uri = aws_lambda_function.filaments_api.invoke_arn
}
resource "aws_api_gateway_integration" "filament_get" {
rest_api_id = aws_api_gateway_rest_api.api.id
resource_id = aws_api_gateway_resource.filament.id
http_method = aws_api_gateway_method.get_filament.http_method
integration_http_method = "POST"
type = "AWS_PROXY"
uri = aws_lambda_function.filaments_api.invoke_arn
}
resource "aws_api_gateway_integration" "filament_put" {
rest_api_id = aws_api_gateway_rest_api.api.id
resource_id = aws_api_gateway_resource.filament.id
http_method = aws_api_gateway_method.put_filament.http_method
integration_http_method = "POST"
type = "AWS_PROXY"
uri = aws_lambda_function.filaments_api.invoke_arn
}
resource "aws_api_gateway_integration" "filament_delete" {
rest_api_id = aws_api_gateway_rest_api.api.id
resource_id = aws_api_gateway_resource.filament.id
http_method = aws_api_gateway_method.delete_filament.http_method
integration_http_method = "POST"
type = "AWS_PROXY"
uri = aws_lambda_function.filaments_api.invoke_arn
}
resource "aws_api_gateway_integration" "login" {
rest_api_id = aws_api_gateway_rest_api.api.id
resource_id = aws_api_gateway_resource.login.id
http_method = aws_api_gateway_method.login.http_method
integration_http_method = "POST"
type = "AWS_PROXY"
uri = aws_lambda_function.auth_api.invoke_arn
}
# OPTIONS integrations for CORS
resource "aws_api_gateway_integration" "options_filaments" {
rest_api_id = aws_api_gateway_rest_api.api.id
resource_id = aws_api_gateway_resource.filaments.id
http_method = aws_api_gateway_method.options_filaments.http_method
integration_http_method = "POST"
type = "AWS_PROXY"
uri = aws_lambda_function.filaments_api.invoke_arn
}
resource "aws_api_gateway_integration" "options_filament" {
rest_api_id = aws_api_gateway_rest_api.api.id
resource_id = aws_api_gateway_resource.filament.id
http_method = aws_api_gateway_method.options_filament.http_method
integration_http_method = "POST"
type = "AWS_PROXY"
uri = aws_lambda_function.filaments_api.invoke_arn
}
resource "aws_api_gateway_integration" "options_login" {
rest_api_id = aws_api_gateway_rest_api.api.id
resource_id = aws_api_gateway_resource.login.id
http_method = aws_api_gateway_method.options_login.http_method
integration_http_method = "POST"
type = "AWS_PROXY"
uri = aws_lambda_function.auth_api.invoke_arn
}
# Lambda permissions for API Gateway
resource "aws_lambda_permission" "api_gateway_filaments" {
statement_id = "AllowAPIGatewayInvoke"
action = "lambda:InvokeFunction"
function_name = aws_lambda_function.filaments_api.function_name
principal = "apigateway.amazonaws.com"
source_arn = "${aws_api_gateway_rest_api.api.execution_arn}/*/*"
}
resource "aws_lambda_permission" "api_gateway_auth" {
statement_id = "AllowAPIGatewayInvoke"
action = "lambda:InvokeFunction"
function_name = aws_lambda_function.auth_api.function_name
principal = "apigateway.amazonaws.com"
source_arn = "${aws_api_gateway_rest_api.api.execution_arn}/*/*"
}
# API Gateway Deployment
resource "aws_api_gateway_deployment" "api" {
rest_api_id = aws_api_gateway_rest_api.api.id
depends_on = [
aws_api_gateway_integration.filaments_get,
aws_api_gateway_integration.filaments_post,
aws_api_gateway_integration.filament_get,
aws_api_gateway_integration.filament_put,
aws_api_gateway_integration.filament_delete,
aws_api_gateway_integration.login,
aws_api_gateway_integration.options_filaments,
aws_api_gateway_integration.options_filament,
aws_api_gateway_integration.options_login
]
lifecycle {
create_before_destroy = true
}
}
# API Gateway Stage
resource "aws_api_gateway_stage" "api" {
deployment_id = aws_api_gateway_deployment.api.id
rest_api_id = aws_api_gateway_rest_api.api.id
stage_name = var.environment
}

View File

@@ -0,0 +1,22 @@
# Cloudflare DNS configuration
provider "cloudflare" {
api_token = var.cloudflare_api_token
}
# Data source to find the zone
data "cloudflare_zone" "main" {
count = var.domain_name != "" && var.cloudflare_api_token != "" ? 1 : 0
name = var.domain_name
}
# Create CNAME record for API subdomain
resource "cloudflare_record" "api" {
count = var.domain_name != "" && var.cloudflare_api_token != "" ? 1 : 0
zone_id = data.cloudflare_zone.main[0].id
name = "api"
content = replace(replace(aws_api_gateway_stage.api.invoke_url, "https://", ""), "/production", "")
type = "CNAME"
ttl = 120
proxied = false
comment = "API Gateway endpoint"
}

52
terraform/dynamodb.tf Normal file
View File

@@ -0,0 +1,52 @@
# DynamoDB table for storing filament data
resource "aws_dynamodb_table" "filaments" {
name = "${var.app_name}-filaments"
billing_mode = "PAY_PER_REQUEST"
hash_key = "id"
attribute {
name = "id"
type = "S"
}
attribute {
name = "brand"
type = "S"
}
attribute {
name = "tip"
type = "S"
}
attribute {
name = "status"
type = "S"
}
# Global secondary index for querying by brand
global_secondary_index {
name = "brand-index"
hash_key = "brand"
projection_type = "ALL"
}
# Global secondary index for querying by type
global_secondary_index {
name = "tip-index"
hash_key = "tip"
projection_type = "ALL"
}
# Global secondary index for querying by status
global_secondary_index {
name = "status-index"
hash_key = "status"
projection_type = "ALL"
}
tags = {
Name = "${var.app_name}-filaments"
Environment = var.environment
}
}

110
terraform/lambda.tf Normal file
View File

@@ -0,0 +1,110 @@
# IAM role for Lambda functions
resource "aws_iam_role" "lambda_role" {
name = "${var.app_name}-lambda-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = {
Service = "lambda.amazonaws.com"
}
}
]
})
}
# IAM policy for Lambda to access DynamoDB
resource "aws_iam_role_policy" "lambda_dynamodb_policy" {
name = "${var.app_name}-lambda-dynamodb-policy"
role = aws_iam_role.lambda_role.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = [
"dynamodb:GetItem",
"dynamodb:PutItem",
"dynamodb:UpdateItem",
"dynamodb:DeleteItem",
"dynamodb:Scan",
"dynamodb:Query"
]
Resource = [
aws_dynamodb_table.filaments.arn,
"${aws_dynamodb_table.filaments.arn}/index/*"
]
},
{
Effect = "Allow"
Action = [
"logs:CreateLogGroup",
"logs:CreateLogStream",
"logs:PutLogEvents"
]
Resource = "arn:aws:logs:*:*:*"
}
]
})
}
# Lambda function for filaments CRUD
resource "aws_lambda_function" "filaments_api" {
filename = data.archive_file.filaments_lambda_zip.output_path
function_name = "${var.app_name}-filaments-api"
role = aws_iam_role.lambda_role.arn
handler = "index.handler"
runtime = "nodejs18.x"
timeout = 30
memory_size = 256
source_code_hash = data.archive_file.filaments_lambda_zip.output_base64sha256
environment {
variables = {
TABLE_NAME = aws_dynamodb_table.filaments.name
CORS_ORIGIN = var.domain_name != "" ? "https://${var.domain_name}" : "*"
}
}
depends_on = [aws_iam_role_policy.lambda_dynamodb_policy]
}
# Lambda function for authentication
resource "aws_lambda_function" "auth_api" {
filename = data.archive_file.auth_lambda_zip.output_path
function_name = "${var.app_name}-auth-api"
role = aws_iam_role.lambda_role.arn
handler = "index.handler"
runtime = "nodejs18.x"
timeout = 10
memory_size = 128
source_code_hash = data.archive_file.auth_lambda_zip.output_base64sha256
environment {
variables = {
JWT_SECRET = var.jwt_secret
ADMIN_USERNAME = var.admin_username
ADMIN_PASSWORD_HASH = var.admin_password_hash
CORS_ORIGIN = var.domain_name != "" ? "https://${var.domain_name}" : "*"
}
}
depends_on = [aws_iam_role_policy.lambda_dynamodb_policy]
}
# Archive files for Lambda deployment
data "archive_file" "filaments_lambda_zip" {
type = "zip"
source_dir = "${path.module}/../lambda/filaments"
output_path = "${path.module}/../lambda/filaments.zip"
}
data "archive_file" "auth_lambda_zip" {
type = "zip"
source_dir = "${path.module}/../lambda/auth"
output_path = "${path.module}/../lambda/auth.zip"
}

View File

@@ -4,6 +4,10 @@ terraform {
source = "hashicorp/aws" source = "hashicorp/aws"
version = "~> 5.0" version = "~> 5.0"
} }
cloudflare = {
source = "cloudflare/cloudflare"
version = "~> 4.0"
}
} }
required_version = ">= 1.0" required_version = ">= 1.0"
} }
@@ -17,8 +21,8 @@ resource "aws_amplify_app" "filamenteka" {
repository = var.github_repository repository = var.github_repository
platform = "WEB" platform = "WEB"
# GitHub access token for private repos # GitHub access token for private repos (optional for public repos)
access_token = var.github_token # access_token = var.github_token
# Build settings for Next.js # Build settings for Next.js
build_spec = <<-EOT build_spec = <<-EOT
@@ -48,6 +52,7 @@ resource "aws_amplify_app" "filamenteka" {
CONFLUENCE_API_URL = var.confluence_api_url CONFLUENCE_API_URL = var.confluence_api_url
CONFLUENCE_TOKEN = var.confluence_token CONFLUENCE_TOKEN = var.confluence_token
CONFLUENCE_PAGE_ID = var.confluence_page_id CONFLUENCE_PAGE_ID = var.confluence_page_id
NEXT_PUBLIC_API_URL = aws_api_gateway_stage.api.invoke_url
} }
# Custom rules for single-page app # Custom rules for single-page app

View File

@@ -18,3 +18,17 @@ output "custom_domain_url" {
value = var.domain_name != "" ? "https://${var.domain_name}" : "Not configured" value = var.domain_name != "" ? "https://${var.domain_name}" : "Not configured"
} }
output "api_url" {
description = "API Gateway URL"
value = aws_api_gateway_stage.api.invoke_url
}
output "dynamodb_table_name" {
description = "DynamoDB table name"
value = aws_dynamodb_table.filaments.name
}
output "api_custom_url" {
description = "Custom API URL via Cloudflare"
value = var.domain_name != "" && var.cloudflare_api_token != "" ? "https://api.${var.domain_name}" : "Use api_url output instead"
}

View File

@@ -7,5 +7,10 @@ confluence_api_url = "https://your-domain.atlassian.net"
confluence_token = "your_confluence_api_token" confluence_token = "your_confluence_api_token"
confluence_page_id = "your_confluence_page_id" confluence_page_id = "your_confluence_page_id"
# Admin Authentication
jwt_secret = "your-secret-key-at-least-32-characters-long"
admin_username = "admin"
admin_password_hash = "bcrypt-hash-generated-by-generate-password-hash.js"
# Optional: Custom domain # Optional: Custom domain
# domain_name = "filamenteka.yourdomain.com" # domain_name = "filamenteka.yourdomain.com"

View File

@@ -36,3 +36,34 @@ variable "environment" {
type = string type = string
default = "production" default = "production"
} }
variable "app_name" {
description = "Application name"
type = string
default = "filamenteka"
}
variable "jwt_secret" {
description = "JWT secret for authentication"
type = string
sensitive = true
}
variable "admin_username" {
description = "Admin username"
type = string
default = "admin"
}
variable "admin_password_hash" {
description = "BCrypt hash of admin password"
type = string
sensitive = true
}
variable "cloudflare_api_token" {
description = "Cloudflare API token for DNS management"
type = string
default = ""
sensitive = true
}