= ({ filaments, loa
|
{filament.weight.value}{filament.weight.unit}
|
+
+ {(() => {
+ // PLA Basic pricing logic
+ if (filament.material.base === 'PLA' && !filament.material.modifier) {
+ if (filament.condition.isRefill && filament.condition.storageCondition !== 'opened') {
+ return '3.499 RSD';
+ } else if (!filament.condition.isRefill && filament.condition.storageCondition === 'vacuum') {
+ return '3.999 RSD';
+ }
+ }
+ // Show original price if available
+ return filament.pricing.purchasePrice ?
+ `${filament.pricing.purchasePrice.toLocaleString('sr-RS')} ${filament.pricing.currency}` :
+ '-';
+ })()}
+ |
{filament.condition.isRefill && (
- Punjenje
-
- )}
- {filament.inventory.available === 0 && (
-
- Nema na stanju
+ Refill
)}
{filament.inventory.available === 1 && (
@@ -293,7 +329,7 @@ export const FilamentTableV2: React.FC = ({ filaments, loa
- Prikazano {filteredAndSortedFilaments.length} od {normalizedFilaments.length} filamenata
+ Prikazano {filteredAndSortedFilaments.length} dostupnih filamenata
);
diff --git a/src/components/MaterialBadge.tsx b/src/components/MaterialBadge.tsx
index 01581e8..132040c 100644
--- a/src/components/MaterialBadge.tsx
+++ b/src/components/MaterialBadge.tsx
@@ -25,15 +25,15 @@ export const MaterialBadge: React.FC = ({ base, modifier, cl
const getModifierIcon = () => {
switch (modifier) {
case 'Silk':
- return '✨';
+ return 'S';
case 'Matte':
- return '🔵';
+ return 'M';
case 'Glow':
- return '💡';
+ return 'G';
case 'Wood':
- return '🪵';
+ return 'W';
case 'CF':
- return '⚫';
+ return 'CF';
default:
return null;
}
@@ -46,7 +46,7 @@ export const MaterialBadge: React.FC = ({ base, modifier, cl
{modifier && (
- {getModifierIcon()} {modifier}
+ {getModifierIcon() && {getModifierIcon()}} {modifier}
)}
diff --git a/src/services/api.ts b/src/services/api.ts
new file mode 100644
index 0000000..3a6f67b
--- /dev/null
+++ b/src/services/api.ts
@@ -0,0 +1,86 @@
+import axios from 'axios';
+
+const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000/api';
+
+// Create axios instance with default config
+const api = axios.create({
+ baseURL: API_URL,
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+});
+
+// Add auth token to requests
+api.interceptors.request.use((config) => {
+ const token = localStorage.getItem('authToken');
+ if (token) {
+ config.headers.Authorization = `Bearer ${token}`;
+ }
+ return config;
+});
+
+// Handle auth errors
+api.interceptors.response.use(
+ (response) => response,
+ (error) => {
+ if (error.response?.status === 401 || error.response?.status === 403) {
+ localStorage.removeItem('authToken');
+ localStorage.removeItem('tokenExpiry');
+ window.location.href = '/upadaj';
+ }
+ return Promise.reject(error);
+ }
+);
+
+export const authService = {
+ login: async (username: string, password: string) => {
+ const response = await api.post('/login', { username, password });
+ return response.data;
+ },
+};
+
+export const colorService = {
+ getAll: async () => {
+ const response = await api.get('/colors');
+ return response.data;
+ },
+
+ create: async (color: { name: string; hex: string }) => {
+ const response = await api.post('/colors', color);
+ return response.data;
+ },
+
+ update: async (id: string, color: { name: string; hex: string }) => {
+ const response = await api.put(`/colors/${id}`, color);
+ return response.data;
+ },
+
+ delete: async (id: string) => {
+ const response = await api.delete(`/colors/${id}`);
+ return response.data;
+ },
+};
+
+export const filamentService = {
+ getAll: async () => {
+ const response = await api.get('/filaments');
+ return response.data;
+ },
+
+ create: async (filament: any) => {
+ const response = await api.post('/filaments', filament);
+ return response.data;
+ },
+
+ update: async (id: string, filament: any) => {
+ const response = await api.put(`/filaments/${id}`, filament);
+ return response.data;
+ },
+
+ delete: async (id: string) => {
+ const response = await api.delete(`/filaments/${id}`);
+ return response.data;
+ },
+};
+
+export default api;
\ No newline at end of file
diff --git a/src/styles/index.css b/src/styles/index.css
index bd6213e..ab8092f 100644
--- a/src/styles/index.css
+++ b/src/styles/index.css
@@ -1,3 +1,23 @@
@tailwind base;
@tailwind components;
-@tailwind utilities;
\ No newline at end of file
+@tailwind utilities;
+
+/* Prevent white flash on admin pages */
+@layer base {
+ html {
+ background-color: rgb(249 250 251);
+ }
+
+ html.dark {
+ background-color: rgb(17 24 39);
+ }
+
+ body {
+ @apply bg-gray-50 dark:bg-gray-900 transition-none;
+ }
+
+ /* Disable transitions on page load to prevent flash */
+ .no-transitions * {
+ transition: none !important;
+ }
+}
\ No newline at end of file
diff --git a/src/styles/select.css b/src/styles/select.css
index 4131ff1..c0dbf3c 100644
--- a/src/styles/select.css
+++ b/src/styles/select.css
@@ -1,24 +1,98 @@
/* 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;
+ /* Remove all native styling */
+ -webkit-appearance: none !important;
+ -moz-appearance: none !important;
+ appearance: none !important;
+
+ /* Custom arrow */
+ 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") !important;
+ background-repeat: no-repeat !important;
+ background-position: right 0.5rem center !important;
+ background-size: 1.5em 1.5em !important;
+ padding-right: 2.5rem !important;
+
+ /* Ensure consistent rendering */
+ border-radius: 0.375rem !important;
+ outline: none;
+ cursor: pointer;
+
+ /* Safari-specific fixes */
+ -webkit-border-radius: 0.375rem !important;
+ -webkit-padding-end: 2.5rem !important;
+ -webkit-padding-start: 0.75rem !important;
+ background-color: field !important;
+}
+
+/* Remove Safari's native dropdown arrow */
+.custom-select::-webkit-calendar-picker-indicator {
+ display: none !important;
+ -webkit-appearance: none !important;
+}
+
+/* Additional Safari arrow removal */
+.custom-select::-webkit-inner-spin-button,
+.custom-select::-webkit-outer-spin-button {
+ -webkit-appearance: none !important;
+ margin: 0;
}
.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");
+ 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") !important;
+ background-color: rgb(55 65 81) !important;
}
-/* Safari-specific fixes */
-@media not all and (min-resolution:.001dpcm) {
- @supports (-webkit-appearance:none) {
- .custom-select {
- padding-right: 2.5rem;
- }
+/* Focus styles */
+.custom-select:focus {
+ outline: 2px solid transparent;
+ outline-offset: 2px;
+ box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.5);
+ border-color: #3b82f6;
+}
+
+/* Hover styles */
+.custom-select:hover:not(:disabled) {
+ border-color: #9ca3af;
+}
+
+/* Disabled styles */
+.custom-select:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+/* Safari-specific overrides */
+@supports (-webkit-appearance: none) {
+ .custom-select {
+ /* Force removal of native arrow in Safari */
+ background-origin: content-box !important;
+ text-indent: 0.01px;
+ text-overflow: '';
+ }
+
+ /* Fix option styling in Safari */
+ .custom-select option {
+ background-color: white;
+ color: black;
+ }
+
+ .dark .custom-select option {
+ background-color: #1f2937;
+ color: white;
+ }
+}
+
+/* Additional Safari fix for newer versions */
+@media screen and (-webkit-min-device-pixel-ratio:0) {
+ select.custom-select {
+ -webkit-appearance: none !important;
+ background-position: right 0.5rem center !important;
+ }
+}
+
+/* Fix for Safari on iOS */
+@supports (-webkit-touch-callout: none) {
+ .custom-select {
+ -webkit-appearance: none !important;
}
}
\ No newline at end of file
diff --git a/src/types/filament.ts b/src/types/filament.ts
index a50f5fe..17338fe 100644
--- a/src/types/filament.ts
+++ b/src/types/filament.ts
@@ -4,6 +4,7 @@ export interface Filament {
tip: string;
finish: string;
boja: string;
+ bojaHex?: string;
refill: string;
vakum: string;
otvoreno: string;
diff --git a/src/types/filament.v2.ts b/src/types/filament.v2.ts
index 0c5f4d1..2637c9e 100644
--- a/src/types/filament.v2.ts
+++ b/src/types/filament.v2.ts
@@ -65,7 +65,6 @@ export interface LegacyFields {
export interface FilamentV2 {
// Identifiers
id: string;
- sku?: string;
// Product Info
brand: string;
diff --git a/terraform/alb.tf b/terraform/alb.tf
new file mode 100644
index 0000000..9d34cc8
--- /dev/null
+++ b/terraform/alb.tf
@@ -0,0 +1,162 @@
+# Application Load Balancer
+resource "aws_lb" "api" {
+ name = "${var.app_name}-api-alb"
+ internal = false
+ load_balancer_type = "application"
+ security_groups = [aws_security_group.alb.id]
+ subnets = aws_subnet.public[*].id
+
+ enable_deletion_protection = false
+ enable_http2 = true
+
+ tags = {
+ Name = "${var.app_name}-api-alb"
+ }
+}
+
+# ALB Security Group
+resource "aws_security_group" "alb" {
+ name = "${var.app_name}-alb-sg"
+ description = "Security group for ALB"
+ vpc_id = aws_vpc.main.id
+
+ ingress {
+ from_port = 80
+ to_port = 80
+ protocol = "tcp"
+ cidr_blocks = ["0.0.0.0/0"]
+ description = "HTTP from anywhere"
+ }
+
+ ingress {
+ from_port = 443
+ to_port = 443
+ protocol = "tcp"
+ cidr_blocks = ["0.0.0.0/0"]
+ description = "HTTPS from anywhere"
+ }
+
+ egress {
+ from_port = 0
+ to_port = 0
+ protocol = "-1"
+ cidr_blocks = ["0.0.0.0/0"]
+ description = "Allow all outbound"
+ }
+
+ tags = {
+ Name = "${var.app_name}-alb-sg"
+ }
+}
+
+# Target Group
+resource "aws_lb_target_group" "api" {
+ name = "${var.app_name}-api-tg"
+ port = 80
+ protocol = "HTTP"
+ vpc_id = aws_vpc.main.id
+
+ health_check {
+ enabled = true
+ healthy_threshold = 2
+ interval = 30
+ matcher = "200"
+ path = "/"
+ port = "traffic-port"
+ protocol = "HTTP"
+ timeout = 5
+ unhealthy_threshold = 2
+ }
+
+ tags = {
+ Name = "${var.app_name}-api-tg"
+ }
+}
+
+# Attach EC2 instance to target group
+resource "aws_lb_target_group_attachment" "api" {
+ target_group_arn = aws_lb_target_group.api.arn
+ target_id = aws_instance.api.id
+ port = 80
+}
+
+# HTTP Listener (redirects to HTTPS)
+resource "aws_lb_listener" "api_http" {
+ load_balancer_arn = aws_lb.api.arn
+ port = "80"
+ protocol = "HTTP"
+
+ default_action {
+ type = "redirect"
+
+ redirect {
+ port = "443"
+ protocol = "HTTPS"
+ status_code = "HTTP_301"
+ }
+ }
+}
+
+# Request ACM certificate
+resource "aws_acm_certificate" "api" {
+ domain_name = "api.${var.domain_name}"
+ validation_method = "DNS"
+
+ lifecycle {
+ create_before_destroy = true
+ }
+
+ tags = {
+ Name = "${var.app_name}-api-cert"
+ }
+}
+
+# Create DNS validation record in Cloudflare
+resource "cloudflare_record" "api_cert_validation" {
+ for_each = {
+ for dvo in aws_acm_certificate.api.domain_validation_options : dvo.domain_name => {
+ name = dvo.resource_record_name
+ record = dvo.resource_record_value
+ type = dvo.resource_record_type
+ }
+ }
+
+ zone_id = data.cloudflare_zone.domain[0].id
+ name = replace(each.value.name, ".${var.domain_name}.", "")
+ value = trimsuffix(each.value.record, ".")
+ type = each.value.type
+ ttl = 60
+ proxied = false
+}
+
+# Certificate validation
+resource "aws_acm_certificate_validation" "api" {
+ certificate_arn = aws_acm_certificate.api.arn
+ validation_record_fqdns = [for record in cloudflare_record.api_cert_validation : record.hostname]
+}
+
+# HTTPS Listener
+resource "aws_lb_listener" "api_https" {
+ load_balancer_arn = aws_lb.api.arn
+ port = "443"
+ protocol = "HTTPS"
+ ssl_policy = "ELBSecurityPolicy-TLS13-1-2-2021-06"
+ certificate_arn = aws_acm_certificate_validation.api.certificate_arn
+
+ default_action {
+ type = "forward"
+ target_group_arn = aws_lb_target_group.api.arn
+ }
+}
+
+# Output ALB DNS
+output "alb_dns_name" {
+ value = aws_lb.api.dns_name
+ description = "ALB DNS name"
+}
+
+# Output ALB Zone ID (needed for Route53 alias)
+output "alb_zone_id" {
+ value = aws_lb.api.zone_id
+ description = "ALB Zone ID"
+}
\ No newline at end of file
diff --git a/terraform/api_gateway.tf b/terraform/api_gateway.tf
deleted file mode 100644
index 998713f..0000000
--- a/terraform/api_gateway.tf
+++ /dev/null
@@ -1,281 +0,0 @@
-# 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
-}
\ No newline at end of file
diff --git a/terraform/cloudflare-api.tf b/terraform/cloudflare-api.tf
new file mode 100644
index 0000000..a0d9fe4
--- /dev/null
+++ b/terraform/cloudflare-api.tf
@@ -0,0 +1,18 @@
+# Cloudflare DNS for API subdomain
+data "cloudflare_zone" "domain" {
+ count = var.domain_name != "" && var.cloudflare_api_token != "" ? 1 : 0
+ name = var.domain_name
+}
+
+# CNAME record for api.filamenteka.rs pointing to ALB
+resource "cloudflare_record" "api" {
+ count = var.domain_name != "" && var.cloudflare_api_token != "" ? 1 : 0
+ zone_id = data.cloudflare_zone.domain[0].id
+ name = "api"
+ type = "CNAME"
+ value = aws_lb.api.dns_name
+ ttl = 1
+ proxied = false
+ comment = "ALB API endpoint with HTTPS"
+}
+
diff --git a/terraform/cloudflare-dns.tf b/terraform/cloudflare-dns.tf
deleted file mode 100644
index 00ab07e..0000000
--- a/terraform/cloudflare-dns.tf
+++ /dev/null
@@ -1,22 +0,0 @@
-# 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"
-}
\ No newline at end of file
diff --git a/terraform/dynamodb.tf b/terraform/dynamodb.tf
deleted file mode 100644
index e4b9219..0000000
--- a/terraform/dynamodb.tf
+++ /dev/null
@@ -1,52 +0,0 @@
-# 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
- }
-}
\ No newline at end of file
diff --git a/terraform/ec2-api.tf b/terraform/ec2-api.tf
new file mode 100644
index 0000000..1d06dd3
--- /dev/null
+++ b/terraform/ec2-api.tf
@@ -0,0 +1,136 @@
+# Security group for API instance
+resource "aws_security_group" "api_instance" {
+ name = "${var.app_name}-api-instance-sg"
+ description = "Security group for API EC2 instance"
+ vpc_id = aws_vpc.main.id
+
+ ingress {
+ from_port = 80
+ to_port = 80
+ protocol = "tcp"
+ security_groups = [aws_security_group.alb.id]
+ description = "HTTP from ALB only"
+ }
+
+
+ ingress {
+ from_port = 22
+ to_port = 22
+ protocol = "tcp"
+ cidr_blocks = ["0.0.0.0/0"]
+ }
+
+ egress {
+ from_port = 0
+ to_port = 0
+ protocol = "-1"
+ cidr_blocks = ["0.0.0.0/0"]
+ }
+
+ tags = {
+ Name = "${var.app_name}-api-instance-sg"
+ }
+}
+
+# IAM role for EC2 instance
+resource "aws_iam_role" "api_instance" {
+ name = "${var.app_name}-api-instance-role"
+
+ assume_role_policy = jsonencode({
+ Version = "2012-10-17"
+ Statement = [
+ {
+ Action = "sts:AssumeRole"
+ Effect = "Allow"
+ Principal = {
+ Service = "ec2.amazonaws.com"
+ }
+ }
+ ]
+ })
+}
+
+# IAM policy for ECR access
+resource "aws_iam_role_policy" "ecr_access" {
+ name = "${var.app_name}-ecr-access"
+ role = aws_iam_role.api_instance.id
+
+ policy = jsonencode({
+ Version = "2012-10-17"
+ Statement = [
+ {
+ Effect = "Allow"
+ Action = [
+ "ecr:GetAuthorizationToken",
+ "ecr:BatchCheckLayerAvailability",
+ "ecr:GetDownloadUrlForLayer",
+ "ecr:BatchGetImage"
+ ]
+ Resource = "*"
+ }
+ ]
+ })
+}
+
+# Instance profile
+resource "aws_iam_instance_profile" "api" {
+ name = "${var.app_name}-api-instance-profile"
+ role = aws_iam_role.api_instance.name
+}
+
+# Get latest Amazon Linux 2 AMI
+data "aws_ami" "amazon_linux_2" {
+ most_recent = true
+ owners = ["amazon"]
+
+ filter {
+ name = "name"
+ values = ["amzn2-ami-hvm-*-x86_64-gp2"]
+ }
+
+ filter {
+ name = "virtualization-type"
+ values = ["hvm"]
+ }
+}
+
+# EC2 instance for API
+resource "aws_instance" "api" {
+ ami = data.aws_ami.amazon_linux_2.id
+ instance_type = "t3.micro"
+ subnet_id = aws_subnet.public[0].id
+ vpc_security_group_ids = [aws_security_group.api_instance.id]
+
+ associate_public_ip_address = true
+ iam_instance_profile = aws_iam_instance_profile.api.name
+
+ user_data = base64encode(templatefile("${path.module}/user-data.sh", {
+ database_url = "postgresql://${aws_db_instance.filamenteka.username}:${random_password.db_password.result}@${aws_db_instance.filamenteka.endpoint}/${aws_db_instance.filamenteka.db_name}?sslmode=require"
+ jwt_secret = random_password.jwt_secret.result
+ admin_password = var.admin_password
+ ecr_url = aws_ecr_repository.api.repository_url
+ aws_region = var.aws_region
+ }))
+
+ tags = {
+ Name = "${var.app_name}-api-instance"
+ }
+
+ depends_on = [aws_db_instance.filamenteka]
+}
+
+# Elastic IP for API instance
+resource "aws_eip" "api" {
+ instance = aws_instance.api.id
+ domain = "vpc"
+
+ tags = {
+ Name = "${var.app_name}-api-eip"
+ }
+}
+
+# Output the API URL
+output "api_instance_url" {
+ value = "http://${aws_eip.api.public_ip}"
+ description = "API instance URL"
+}
\ No newline at end of file
diff --git a/terraform/ecr.tf b/terraform/ecr.tf
new file mode 100644
index 0000000..141ba72
--- /dev/null
+++ b/terraform/ecr.tf
@@ -0,0 +1,34 @@
+# ECR Repository for API
+resource "aws_ecr_repository" "api" {
+ name = "${var.app_name}-api"
+ image_tag_mutability = "MUTABLE"
+
+ image_scanning_configuration {
+ scan_on_push = true
+ }
+
+ tags = {
+ Name = "${var.app_name}-api"
+ }
+}
+
+# ECR Lifecycle Policy
+resource "aws_ecr_lifecycle_policy" "api" {
+ repository = aws_ecr_repository.api.name
+
+ policy = jsonencode({
+ rules = [{
+ rulePriority = 1
+ description = "Keep last 10 images"
+ selection = {
+ tagStatus = "tagged"
+ tagPrefixList = ["v"]
+ countType = "imageCountMoreThan"
+ countNumber = 10
+ }
+ action = {
+ type = "expire"
+ }
+ }]
+ })
+}
\ No newline at end of file
diff --git a/terraform/lambda.tf b/terraform/lambda.tf
deleted file mode 100644
index b4b289c..0000000
--- a/terraform/lambda.tf
+++ /dev/null
@@ -1,110 +0,0 @@
-# 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 = "*"
- }
- }
-
- 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 = "*"
- }
- }
-
- 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"
-}
\ No newline at end of file
diff --git a/terraform/main.tf b/terraform/main.tf
index 8c97ca1..45b5fa0 100644
--- a/terraform/main.tf
+++ b/terraform/main.tf
@@ -16,6 +16,10 @@ provider "aws" {
region = "eu-central-1" # Frankfurt
}
+provider "cloudflare" {
+ api_token = var.cloudflare_api_token != "" ? var.cloudflare_api_token : "dummy" # Dummy token if not provided
+}
+
resource "aws_amplify_app" "filamenteka" {
name = "filamenteka"
repository = var.github_repository
@@ -49,7 +53,7 @@ resource "aws_amplify_app" "filamenteka" {
# Environment variables
environment_variables = {
- NEXT_PUBLIC_API_URL = aws_api_gateway_stage.api.invoke_url
+ NEXT_PUBLIC_API_URL = "https://api.filamenteka.rs/api" # Using Cloudflare proxied subdomain
}
# Custom rules for single-page app
diff --git a/terraform/outputs.tf b/terraform/outputs.tf
index d4be428..c1b626c 100644
--- a/terraform/outputs.tf
+++ b/terraform/outputs.tf
@@ -18,17 +18,28 @@ output "custom_domain_url" {
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 "rds_endpoint" {
+ value = aws_db_instance.filamenteka.endpoint
+ description = "RDS instance endpoint"
}
-output "dynamodb_table_name" {
- description = "DynamoDB table name"
- value = aws_dynamodb_table.filaments.name
+output "rds_database_name" {
+ value = aws_db_instance.filamenteka.db_name
+ description = "Database 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"
+output "rds_username" {
+ value = aws_db_instance.filamenteka.username
+ description = "Database username"
+}
+
+output "rds_password_secret_arn" {
+ value = aws_secretsmanager_secret.db_credentials.arn
+ description = "ARN of the secret containing the database password"
+}
+
+output "database_url" {
+ value = "postgresql://${aws_db_instance.filamenteka.username}:[PASSWORD]@${aws_db_instance.filamenteka.endpoint}/${aws_db_instance.filamenteka.db_name}"
+ description = "Database connection URL (replace [PASSWORD] with actual password from Secrets Manager)"
+ sensitive = true
}
diff --git a/terraform/rds.tf b/terraform/rds.tf
new file mode 100644
index 0000000..14c53ba
--- /dev/null
+++ b/terraform/rds.tf
@@ -0,0 +1,98 @@
+# RDS PostgreSQL Database
+resource "aws_db_subnet_group" "filamenteka" {
+ name = "${var.app_name}-db-subnet-group"
+ subnet_ids = aws_subnet.public[*].id
+
+ tags = {
+ Name = "${var.app_name}-db-subnet-group"
+ }
+}
+
+resource "aws_security_group" "rds" {
+ name = "${var.app_name}-rds-sg"
+ description = "Security group for RDS database"
+ vpc_id = aws_vpc.main.id
+
+
+ # Allow access from your local IP for development
+ # IMPORTANT: Replace with your actual IP address
+ ingress {
+ from_port = 5432
+ to_port = 5432
+ protocol = "tcp"
+ cidr_blocks = ["0.0.0.0/0"] # WARNING: This allows access from anywhere. Replace with your IP!
+ description = "Development access - RESTRICT THIS IN PRODUCTION"
+ }
+
+ egress {
+ from_port = 0
+ to_port = 0
+ protocol = "-1"
+ cidr_blocks = ["0.0.0.0/0"]
+ }
+
+ tags = {
+ Name = "${var.app_name}-rds-sg"
+ }
+}
+
+resource "aws_db_instance" "filamenteka" {
+ identifier = var.app_name
+ engine = "postgres"
+ engine_version = "15"
+ instance_class = "db.t3.micro"
+
+ allocated_storage = 20
+ max_allocated_storage = 100
+ storage_type = "gp3"
+ storage_encrypted = true
+
+ db_name = "filamenteka"
+ username = "filamenteka_admin"
+ password = random_password.db_password.result
+
+ # Make it publicly accessible for development
+ publicly_accessible = true
+
+ vpc_security_group_ids = [aws_security_group.rds.id]
+ db_subnet_group_name = aws_db_subnet_group.filamenteka.name
+
+ backup_retention_period = 7
+ backup_window = "03:00-04:00"
+ maintenance_window = "sun:04:00-sun:05:00"
+
+ deletion_protection = false # Set to true in production
+ skip_final_snapshot = true # Set to false in production
+
+ enabled_cloudwatch_logs_exports = ["postgresql"]
+
+ tags = {
+ Name = "${var.app_name}-db"
+ }
+}
+
+resource "random_password" "db_password" {
+ length = 32
+ special = false # RDS doesn't allow certain special characters
+}
+
+resource "aws_secretsmanager_secret" "db_credentials" {
+ name = "${var.app_name}-db-credentials"
+}
+
+resource "aws_secretsmanager_secret_version" "db_credentials" {
+ secret_id = aws_secretsmanager_secret.db_credentials.id
+ secret_string = jsonencode({
+ username = aws_db_instance.filamenteka.username
+ password = random_password.db_password.result
+ host = aws_db_instance.filamenteka.endpoint
+ port = aws_db_instance.filamenteka.port
+ database = aws_db_instance.filamenteka.db_name
+ })
+}
+
+# Random password for JWT
+resource "random_password" "jwt_secret" {
+ length = 64
+ special = false
+}
\ No newline at end of file
diff --git a/terraform/user-data.sh b/terraform/user-data.sh
new file mode 100644
index 0000000..bf85fc1
--- /dev/null
+++ b/terraform/user-data.sh
@@ -0,0 +1,68 @@
+#!/bin/bash
+
+# Update system
+yum update -y
+
+# Install Docker
+amazon-linux-extras install docker -y
+service docker start
+usermod -a -G docker ec2-user
+chkconfig docker on
+
+# Install docker-compose
+curl -L "https://github.com/docker/compose/releases/latest/download/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
+chmod +x /usr/local/bin/docker-compose
+
+# Configure AWS CLI
+aws configure set region ${aws_region}
+
+# Login to ECR
+aws ecr get-login-password --region ${aws_region} | docker login --username AWS --password-stdin ${ecr_url}
+
+# Create environment file
+cat > /home/ec2-user/.env < /home/ec2-user/docker-compose.yml < /etc/systemd/system/api.service < |