create-nextjs-stack 0.1.1 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +502 -31
- package/package.json +1 -1
- package/templates/admin/app/(dashboard)/[resource]/[id]/page.tsx +6 -5
- package/templates/admin/app/(dashboard)/[resource]/new/page.tsx +11 -12
- package/templates/admin/app/(dashboard)/[resource]/page.tsx +4 -3
- package/templates/admin/app/actions/upload.ts +0 -7
- package/templates/admin/app/globals.css +112 -14
- package/templates/admin/components/admin/Sidebar.tsx +2 -5
- package/templates/admin/components.json +22 -0
- package/templates/admin/hooks/useResource.ts +3 -0
- package/templates/admin/lib/services/resource.service.ts +2 -7
- package/templates/admin/lib/supabase/client.ts +8 -4
- package/templates/admin/lib/supabase/server.ts +1 -1
- package/templates/admin/lib/utils.ts +6 -0
- package/templates/admin/middleware.ts +1 -3
- package/templates/admin/next.config.ts +10 -2
- package/templates/admin/package-lock.json +13 -1
- package/templates/admin/package.json +3 -1
- package/templates/web/.env.example +5 -1
- package/templates/web/package-lock.json +49 -18
- package/templates/web/package.json +1 -2
- package/templates/web/postcss.config.mjs +3 -1
- package/templates/web/src/app/api/revalidate/route.ts +46 -87
- package/templates/web/src/app/globals.css +1 -13
- package/templates/web/src/app/layout.tsx +4 -46
- package/templates/web/src/app/robots.ts +1 -1
- package/templates/web/src/app/sitemap.ts +27 -31
- package/templates/web/src/lib/seo/metadata.ts +5 -5
- package/templates/web/src/lib/seo/seo.config.ts +55 -59
- package/templates/web/src/lib/seo/seo.types.ts +1 -7
- package/templates/web/src/lib/services/categories.service.ts +3 -3
- package/templates/web/src/lib/services/clients.service.ts +2 -2
- package/templates/web/src/lib/services/products.service.ts +3 -3
- package/templates/web/src/lib/services/projects.service.ts +3 -3
- package/templates/web/src/lib/services/users.service.ts +2 -2
- package/templates/web/src/lib/supabase/client.ts +1 -1
- package/templates/web/src/lib/supabase/server.ts +1 -1
- package/templates/web/src/store/index.ts +4 -9
- package/templates/admin/app/(dashboard)/categories/[id]/page.tsx +0 -22
- package/templates/admin/app/(dashboard)/categories/new/page.tsx +0 -5
- package/templates/admin/app/(dashboard)/categories/page.tsx +0 -33
- package/templates/admin/app/(dashboard)/clients/[id]/page.tsx +0 -22
- package/templates/admin/app/(dashboard)/clients/new/page.tsx +0 -5
- package/templates/admin/app/(dashboard)/clients/page.tsx +0 -33
- package/templates/admin/app/(dashboard)/products/[id]/page.tsx +0 -22
- package/templates/admin/app/(dashboard)/products/new/page.tsx +0 -5
- package/templates/admin/app/(dashboard)/products/page.tsx +0 -33
- package/templates/admin/app/(dashboard)/projects/[id]/page.tsx +0 -22
- package/templates/admin/app/(dashboard)/projects/new/page.tsx +0 -5
- package/templates/admin/app/(dashboard)/projects/page.tsx +0 -33
- package/templates/admin/app/(dashboard)/users/[id]/page.tsx +0 -22
- package/templates/admin/app/(dashboard)/users/new/page.tsx +0 -5
- package/templates/admin/app/(dashboard)/users/page.tsx +0 -33
- package/templates/admin/components/categories/CategoryForm.tsx +0 -24
- package/templates/admin/components/categories/CategoryList.tsx +0 -113
- package/templates/admin/components/clients/ClientForm.tsx +0 -24
- package/templates/admin/components/clients/ClientList.tsx +0 -113
- package/templates/admin/components/products/ProductForm.tsx +0 -24
- package/templates/admin/components/products/ProductList.tsx +0 -117
- package/templates/admin/components/projects/ProjectForm.tsx +0 -24
- package/templates/admin/components/projects/ProjectList.tsx +0 -121
- package/templates/admin/components/users/UserForm.tsx +0 -39
- package/templates/admin/components/users/UserList.tsx +0 -101
- package/templates/web/src/lib/services/categoryService.ts +0 -251
- package/templates/web/src/lib/services/clientService.ts +0 -132
- package/templates/web/src/lib/services/productService.ts +0 -261
- package/templates/web/src/lib/services/projectService.ts +0 -234
- package/templates/web/src/lib/utils/cache.ts +0 -98
- package/templates/web/src/lib/utils/rate-limiter.ts +0 -102
|
@@ -1,261 +0,0 @@
|
|
|
1
|
-
import { getBrowserClient } from '@/lib/supabase/client';
|
|
2
|
-
import { TABLE_NAMES, CACHE_TIMES } from '@/lib/supabase/constants';
|
|
3
|
-
import { memoryCache } from '@/lib/utils/cache';
|
|
4
|
-
import { perMinuteLimiter } from '@/lib/utils/rate-limiter';
|
|
5
|
-
import type { Product, ProductWithCategory } from '@/lib/supabase/types';
|
|
6
|
-
|
|
7
|
-
interface GetProductsOptions {
|
|
8
|
-
limit?: number;
|
|
9
|
-
offset?: number;
|
|
10
|
-
categoryId?: string;
|
|
11
|
-
featured?: boolean;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* Product Service - Handles all product-related database operations
|
|
16
|
-
* Implements singleton pattern with caching and rate limiting
|
|
17
|
-
*/
|
|
18
|
-
export class ProductService {
|
|
19
|
-
private static instance: ProductService;
|
|
20
|
-
|
|
21
|
-
private constructor() {}
|
|
22
|
-
|
|
23
|
-
static getInstance(): ProductService {
|
|
24
|
-
if (!ProductService.instance) {
|
|
25
|
-
ProductService.instance = new ProductService();
|
|
26
|
-
}
|
|
27
|
-
return ProductService.instance;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* Get all published products with optional filtering
|
|
32
|
-
*/
|
|
33
|
-
async getAll(options: GetProductsOptions = {}): Promise<Product[]> {
|
|
34
|
-
const { limit = 100, offset = 0, categoryId, featured } = options;
|
|
35
|
-
const cacheKey = `products:all:${limit}:${offset}:${categoryId || 'none'}:${featured || 'all'}`;
|
|
36
|
-
|
|
37
|
-
const cached = memoryCache.get<Product[]>(cacheKey);
|
|
38
|
-
if (cached) {
|
|
39
|
-
return cached;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
const allowed = await perMinuteLimiter.checkLimit('products:getAll');
|
|
43
|
-
if (!allowed) {
|
|
44
|
-
throw new Error('Rate limit exceeded. Please try again later.');
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
const supabase = getBrowserClient();
|
|
48
|
-
let query = supabase
|
|
49
|
-
.from(TABLE_NAMES.PRODUCTS)
|
|
50
|
-
.select('*')
|
|
51
|
-
.eq('published', true)
|
|
52
|
-
.order('created_at', { ascending: false })
|
|
53
|
-
.range(offset, offset + limit - 1);
|
|
54
|
-
|
|
55
|
-
if (categoryId) {
|
|
56
|
-
query = query.eq('category_id', categoryId);
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
if (featured !== undefined) {
|
|
60
|
-
query = query.eq('featured', featured);
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
const { data, error } = await query;
|
|
64
|
-
|
|
65
|
-
if (error) {
|
|
66
|
-
console.error('[ProductService] Error fetching products:', error);
|
|
67
|
-
throw new Error(`Failed to fetch products: ${error.message}`);
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
memoryCache.set(cacheKey, data, CACHE_TIMES.PRODUCTS);
|
|
71
|
-
return data;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/**
|
|
75
|
-
* Get product by slug with category information
|
|
76
|
-
*/
|
|
77
|
-
async getBySlug(slug: string): Promise<ProductWithCategory | null> {
|
|
78
|
-
const cacheKey = `products:slug:${slug}`;
|
|
79
|
-
|
|
80
|
-
const cached = memoryCache.get<ProductWithCategory>(cacheKey);
|
|
81
|
-
if (cached) {
|
|
82
|
-
return cached;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
const allowed = await perMinuteLimiter.checkLimit('products:getBySlug');
|
|
86
|
-
if (!allowed) {
|
|
87
|
-
throw new Error('Rate limit exceeded. Please try again later.');
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
const supabase = getBrowserClient();
|
|
91
|
-
const { data, error } = await supabase
|
|
92
|
-
.from(TABLE_NAMES.PRODUCTS)
|
|
93
|
-
.select(`
|
|
94
|
-
*,
|
|
95
|
-
category:categories(*)
|
|
96
|
-
`)
|
|
97
|
-
.eq('slug', slug)
|
|
98
|
-
.eq('published', true)
|
|
99
|
-
.single();
|
|
100
|
-
|
|
101
|
-
if (error) {
|
|
102
|
-
if (error.code === 'PGRST116') {
|
|
103
|
-
return null;
|
|
104
|
-
}
|
|
105
|
-
console.error('[ProductService] Error fetching product:', error);
|
|
106
|
-
throw new Error(`Failed to fetch product: ${error.message}`);
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
const productWithCategory = data as unknown as ProductWithCategory;
|
|
110
|
-
memoryCache.set(cacheKey, productWithCategory, CACHE_TIMES.PRODUCTS);
|
|
111
|
-
return productWithCategory;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
/**
|
|
115
|
-
* Get featured products
|
|
116
|
-
*/
|
|
117
|
-
async getFeatured(): Promise<Product[]> {
|
|
118
|
-
return this.getAll({ featured: true });
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
/**
|
|
122
|
-
* Get products by category
|
|
123
|
-
*/
|
|
124
|
-
async getByCategory(categoryId: string, limit = 100): Promise<Product[]> {
|
|
125
|
-
return this.getAll({ categoryId, limit });
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
/**
|
|
129
|
-
* Get related products (same category, excluding current product)
|
|
130
|
-
*/
|
|
131
|
-
async getRelated(productId: string, categoryId: string, limit = 4): Promise<Product[]> {
|
|
132
|
-
const cacheKey = `products:related:${productId}:${limit}`;
|
|
133
|
-
|
|
134
|
-
const cached = memoryCache.get<Product[]>(cacheKey);
|
|
135
|
-
if (cached) {
|
|
136
|
-
return cached;
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
const supabase = getBrowserClient();
|
|
140
|
-
const { data, error } = await supabase
|
|
141
|
-
.from(TABLE_NAMES.PRODUCTS)
|
|
142
|
-
.select('*')
|
|
143
|
-
.eq('category_id', categoryId)
|
|
144
|
-
.eq('published', true)
|
|
145
|
-
.neq('id', productId)
|
|
146
|
-
.limit(limit);
|
|
147
|
-
|
|
148
|
-
if (error) {
|
|
149
|
-
console.error('[ProductService] Error fetching related products:', error);
|
|
150
|
-
throw new Error(`Failed to fetch related products: ${error.message}`);
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
memoryCache.set(cacheKey, data, CACHE_TIMES.PRODUCTS);
|
|
154
|
-
return data;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
/**
|
|
158
|
-
* Get product by ID
|
|
159
|
-
*/
|
|
160
|
-
async getById(id: string): Promise<Product | null> {
|
|
161
|
-
const cacheKey = `products:id:${id}`;
|
|
162
|
-
|
|
163
|
-
const cached = memoryCache.get<Product>(cacheKey);
|
|
164
|
-
if (cached) {
|
|
165
|
-
return cached;
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
const supabase = getBrowserClient();
|
|
169
|
-
const { data, error } = await supabase
|
|
170
|
-
.from(TABLE_NAMES.PRODUCTS)
|
|
171
|
-
.select('*')
|
|
172
|
-
.eq('id', id)
|
|
173
|
-
.eq('published', true)
|
|
174
|
-
.single();
|
|
175
|
-
|
|
176
|
-
if (error) {
|
|
177
|
-
if (error.code === 'PGRST116') {
|
|
178
|
-
return null;
|
|
179
|
-
}
|
|
180
|
-
console.error('[ProductService] Error fetching product by ID:', error);
|
|
181
|
-
throw new Error(`Failed to fetch product: ${error.message}`);
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
memoryCache.set(cacheKey, data, CACHE_TIMES.PRODUCTS);
|
|
185
|
-
return data;
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
/**
|
|
189
|
-
* Get multiple products by IDs
|
|
190
|
-
*/
|
|
191
|
-
async getByIds(ids: string[]): Promise<Product[]> {
|
|
192
|
-
if (ids.length === 0) {
|
|
193
|
-
return [];
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
const cacheKey = `products:ids:${ids.sort().join(',')}`;
|
|
197
|
-
|
|
198
|
-
const cached = memoryCache.get<Product[]>(cacheKey);
|
|
199
|
-
if (cached) {
|
|
200
|
-
return cached;
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
const supabase = getBrowserClient();
|
|
204
|
-
const { data, error } = await supabase
|
|
205
|
-
.from(TABLE_NAMES.PRODUCTS)
|
|
206
|
-
.select('*')
|
|
207
|
-
.in('id', ids)
|
|
208
|
-
.eq('published', true);
|
|
209
|
-
|
|
210
|
-
if (error) {
|
|
211
|
-
console.error('[ProductService] Error fetching products by IDs:', error);
|
|
212
|
-
throw new Error(`Failed to fetch products: ${error.message}`);
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
memoryCache.set(cacheKey, data, CACHE_TIMES.PRODUCTS);
|
|
216
|
-
return data;
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
/**
|
|
220
|
-
* Get all product slugs for static generation
|
|
221
|
-
*/
|
|
222
|
-
async getAllSlugs(): Promise<string[]> {
|
|
223
|
-
const cacheKey = 'products:slugs:all';
|
|
224
|
-
|
|
225
|
-
const cached = memoryCache.get<string[]>(cacheKey);
|
|
226
|
-
if (cached) {
|
|
227
|
-
return cached;
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
const supabase = getBrowserClient();
|
|
231
|
-
const { data, error } = await supabase
|
|
232
|
-
.from(TABLE_NAMES.PRODUCTS)
|
|
233
|
-
.select('slug')
|
|
234
|
-
.eq('published', true);
|
|
235
|
-
|
|
236
|
-
if (error) {
|
|
237
|
-
console.error('[ProductService] Error fetching product slugs:', error);
|
|
238
|
-
throw new Error(`Failed to fetch product slugs: ${error.message}`);
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
const slugs = (data as { slug: string }[]).map((item) => item.slug);
|
|
242
|
-
memoryCache.set(cacheKey, slugs, CACHE_TIMES.PRODUCTS);
|
|
243
|
-
return slugs;
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
/**
|
|
247
|
-
* Invalidate all product caches
|
|
248
|
-
*/
|
|
249
|
-
invalidateCache(): void {
|
|
250
|
-
memoryCache.invalidate('products:');
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
/**
|
|
254
|
-
* Invalidate specific product cache
|
|
255
|
-
*/
|
|
256
|
-
invalidateProductCache(slug: string): void {
|
|
257
|
-
memoryCache.invalidateKey(`products:slug:${slug}`);
|
|
258
|
-
}
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
export const productService = ProductService.getInstance();
|
|
@@ -1,234 +0,0 @@
|
|
|
1
|
-
import { getBrowserClient } from '@/lib/supabase/client';
|
|
2
|
-
import { TABLE_NAMES, CACHE_TIMES } from '@/lib/supabase/constants';
|
|
3
|
-
import { memoryCache } from '@/lib/utils/cache';
|
|
4
|
-
import { perMinuteLimiter } from '@/lib/utils/rate-limiter';
|
|
5
|
-
import type { Project, ProjectWithProducts } from '@/lib/supabase/types';
|
|
6
|
-
import { productService } from './productService';
|
|
7
|
-
|
|
8
|
-
interface GetProjectsOptions {
|
|
9
|
-
limit?: number;
|
|
10
|
-
offset?: number;
|
|
11
|
-
year?: number;
|
|
12
|
-
featured?: boolean;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
/**
|
|
16
|
-
* Project Service - Handles all project-related database operations
|
|
17
|
-
* Implements singleton pattern with caching and rate limiting
|
|
18
|
-
*/
|
|
19
|
-
export class ProjectService {
|
|
20
|
-
private static instance: ProjectService;
|
|
21
|
-
|
|
22
|
-
private constructor() {}
|
|
23
|
-
|
|
24
|
-
static getInstance(): ProjectService {
|
|
25
|
-
if (!ProjectService.instance) {
|
|
26
|
-
ProjectService.instance = new ProjectService();
|
|
27
|
-
}
|
|
28
|
-
return ProjectService.instance;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
/**
|
|
32
|
-
* Get all published projects with optional filtering
|
|
33
|
-
*/
|
|
34
|
-
async getAll(options: GetProjectsOptions = {}): Promise<Project[]> {
|
|
35
|
-
const { limit = 100, offset = 0, year, featured } = options;
|
|
36
|
-
const cacheKey = `projects:all:${limit}:${offset}:${year || 'all'}:${featured || 'all'}`;
|
|
37
|
-
|
|
38
|
-
const cached = memoryCache.get<Project[]>(cacheKey);
|
|
39
|
-
if (cached) {
|
|
40
|
-
return cached;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
const allowed = await perMinuteLimiter.checkLimit('projects:getAll');
|
|
44
|
-
if (!allowed) {
|
|
45
|
-
throw new Error('Rate limit exceeded. Please try again later.');
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
const supabase = getBrowserClient();
|
|
49
|
-
let query = supabase
|
|
50
|
-
.from(TABLE_NAMES.PROJECTS)
|
|
51
|
-
.select('*')
|
|
52
|
-
.eq('published', true)
|
|
53
|
-
.order('year', { ascending: false })
|
|
54
|
-
.order('created_at', { ascending: false })
|
|
55
|
-
.range(offset, offset + limit - 1);
|
|
56
|
-
|
|
57
|
-
if (year !== undefined) {
|
|
58
|
-
query = query.eq('year', year);
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
if (featured !== undefined) {
|
|
62
|
-
query = query.eq('featured', featured);
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
const { data, error } = await query;
|
|
66
|
-
|
|
67
|
-
if (error) {
|
|
68
|
-
console.error('[ProjectService] Error fetching projects:', error);
|
|
69
|
-
throw new Error(`Failed to fetch projects: ${error.message}`);
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
memoryCache.set(cacheKey, data, CACHE_TIMES.PROJECTS);
|
|
73
|
-
return data as Project[];
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
/**
|
|
77
|
-
* Get project by slug with related products
|
|
78
|
-
*/
|
|
79
|
-
async getBySlug(slug: string): Promise<ProjectWithProducts | null> {
|
|
80
|
-
const cacheKey = `projects:slug:${slug}`;
|
|
81
|
-
|
|
82
|
-
const cached = memoryCache.get<ProjectWithProducts>(cacheKey);
|
|
83
|
-
if (cached) {
|
|
84
|
-
return cached;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
const allowed = await perMinuteLimiter.checkLimit('projects:getBySlug');
|
|
88
|
-
if (!allowed) {
|
|
89
|
-
throw new Error('Rate limit exceeded. Please try again later.');
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
const supabase = getBrowserClient();
|
|
93
|
-
const { data, error } = await supabase
|
|
94
|
-
.from(TABLE_NAMES.PROJECTS)
|
|
95
|
-
.select('*')
|
|
96
|
-
.eq('slug', slug)
|
|
97
|
-
.eq('published', true)
|
|
98
|
-
.single();
|
|
99
|
-
|
|
100
|
-
if (error) {
|
|
101
|
-
if (error.code === 'PGRST116') {
|
|
102
|
-
return null;
|
|
103
|
-
}
|
|
104
|
-
console.error('[ProjectService] Error fetching project:', error);
|
|
105
|
-
throw new Error(`Failed to fetch project: ${error.message}`);
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
// Fetch related products
|
|
109
|
-
let products: import('@/lib/supabase/types').Product[] = [];
|
|
110
|
-
const projectData = data as Project;
|
|
111
|
-
if (projectData.product_ids && projectData.product_ids.length > 0) {
|
|
112
|
-
try {
|
|
113
|
-
products = await productService.getByIds(projectData.product_ids);
|
|
114
|
-
} catch (error) {
|
|
115
|
-
console.error('[ProjectService] Error fetching related products:', error);
|
|
116
|
-
// Continue without products rather than failing
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
const projectWithProducts: ProjectWithProducts = {
|
|
121
|
-
...projectData,
|
|
122
|
-
products,
|
|
123
|
-
};
|
|
124
|
-
|
|
125
|
-
memoryCache.set(cacheKey, projectWithProducts, CACHE_TIMES.PROJECTS);
|
|
126
|
-
return projectWithProducts;
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
/**
|
|
130
|
-
* Get featured projects
|
|
131
|
-
*/
|
|
132
|
-
async getFeatured(): Promise<Project[]> {
|
|
133
|
-
return this.getAll({ featured: true });
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
/**
|
|
137
|
-
* Get projects by year
|
|
138
|
-
*/
|
|
139
|
-
async getByYear(year: number): Promise<Project[]> {
|
|
140
|
-
return this.getAll({ year });
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
/**
|
|
144
|
-
* Get project by ID
|
|
145
|
-
*/
|
|
146
|
-
async getById(id: string): Promise<Project | null> {
|
|
147
|
-
const cacheKey = `projects:id:${id}`;
|
|
148
|
-
|
|
149
|
-
const cached = memoryCache.get<Project>(cacheKey);
|
|
150
|
-
if (cached) {
|
|
151
|
-
return cached;
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
const supabase = getBrowserClient();
|
|
155
|
-
const { data, error } = await supabase
|
|
156
|
-
.from(TABLE_NAMES.PROJECTS)
|
|
157
|
-
.select('*')
|
|
158
|
-
.eq('id', id)
|
|
159
|
-
.eq('published', true)
|
|
160
|
-
.single();
|
|
161
|
-
|
|
162
|
-
if (error) {
|
|
163
|
-
if (error.code === 'PGRST116') {
|
|
164
|
-
return null;
|
|
165
|
-
}
|
|
166
|
-
console.error('[ProjectService] Error fetching project by ID:', error);
|
|
167
|
-
throw new Error(`Failed to fetch project: ${error.message}`);
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
memoryCache.set(cacheKey, data, CACHE_TIMES.PROJECTS);
|
|
171
|
-
return data;
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
/**
|
|
175
|
-
* Get all project slugs for static generation
|
|
176
|
-
*/
|
|
177
|
-
async getAllSlugs(): Promise<string[]> {
|
|
178
|
-
const cacheKey = 'projects:slugs:all';
|
|
179
|
-
|
|
180
|
-
const cached = memoryCache.get<string[]>(cacheKey);
|
|
181
|
-
if (cached) {
|
|
182
|
-
return cached;
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
const supabase = getBrowserClient();
|
|
186
|
-
const { data, error } = await supabase
|
|
187
|
-
.from(TABLE_NAMES.PROJECTS)
|
|
188
|
-
.select('slug')
|
|
189
|
-
.eq('published', true);
|
|
190
|
-
|
|
191
|
-
if (error) {
|
|
192
|
-
console.error('[ProjectService] Error fetching project slugs:', error);
|
|
193
|
-
throw new Error(`Failed to fetch project slugs: ${error.message}`);
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
const slugs = (data as { slug: string }[]).map((item) => item.slug);
|
|
197
|
-
memoryCache.set(cacheKey, slugs, CACHE_TIMES.PROJECTS);
|
|
198
|
-
return slugs;
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
/**
|
|
202
|
-
* Get unique years from all projects
|
|
203
|
-
*/
|
|
204
|
-
async getYears(): Promise<number[]> {
|
|
205
|
-
const cacheKey = 'projects:years';
|
|
206
|
-
|
|
207
|
-
const cached = memoryCache.get<number[]>(cacheKey);
|
|
208
|
-
if (cached) {
|
|
209
|
-
return cached;
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
const projects = await this.getAll();
|
|
213
|
-
const years = [...new Set(projects.map((p) => p.year))].sort((a, b) => b - a);
|
|
214
|
-
|
|
215
|
-
memoryCache.set(cacheKey, years, CACHE_TIMES.PROJECTS);
|
|
216
|
-
return years;
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
/**
|
|
220
|
-
* Invalidate all project caches
|
|
221
|
-
*/
|
|
222
|
-
invalidateCache(): void {
|
|
223
|
-
memoryCache.invalidate('projects:');
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
/**
|
|
227
|
-
* Invalidate specific project cache
|
|
228
|
-
*/
|
|
229
|
-
invalidateProjectCache(slug: string): void {
|
|
230
|
-
memoryCache.invalidateKey(`projects:slug:${slug}`);
|
|
231
|
-
}
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
export const projectService = ProjectService.getInstance();
|
|
@@ -1,98 +0,0 @@
|
|
|
1
|
-
interface CacheEntry<T> {
|
|
2
|
-
data: T;
|
|
3
|
-
timestamp: number;
|
|
4
|
-
ttl: number;
|
|
5
|
-
}
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* In-memory cache with TTL (Time To Live) support
|
|
9
|
-
* Provides fast access to frequently requested data
|
|
10
|
-
*/
|
|
11
|
-
class MemoryCache {
|
|
12
|
-
private cache: Map<string, CacheEntry<unknown>> = new Map();
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* Store data in cache with TTL
|
|
16
|
-
* @param key - Cache key
|
|
17
|
-
* @param data - Data to cache
|
|
18
|
-
* @param ttl - Time to live in seconds
|
|
19
|
-
*/
|
|
20
|
-
set<T>(key: string, data: T, ttl: number): void {
|
|
21
|
-
this.cache.set(key, {
|
|
22
|
-
data,
|
|
23
|
-
timestamp: Date.now(),
|
|
24
|
-
ttl,
|
|
25
|
-
});
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* Retrieve data from cache
|
|
30
|
-
* @param key - Cache key
|
|
31
|
-
* @returns Cached data or null if expired/not found
|
|
32
|
-
*/
|
|
33
|
-
get<T>(key: string): T | null {
|
|
34
|
-
const entry = this.cache.get(key) as CacheEntry<T> | undefined;
|
|
35
|
-
|
|
36
|
-
if (!entry) {
|
|
37
|
-
return null;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
// Check if entry has expired
|
|
41
|
-
const age = Date.now() - entry.timestamp;
|
|
42
|
-
if (age > entry.ttl * 1000) {
|
|
43
|
-
this.cache.delete(key);
|
|
44
|
-
return null;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
return entry.data;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* Check if key exists and is not expired
|
|
52
|
-
* @param key - Cache key
|
|
53
|
-
*/
|
|
54
|
-
has(key: string): boolean {
|
|
55
|
-
return this.get(key) !== null;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/**
|
|
59
|
-
* Invalidate cache entries matching a pattern
|
|
60
|
-
* @param pattern - String pattern to match against keys
|
|
61
|
-
*/
|
|
62
|
-
invalidate(pattern: string): void {
|
|
63
|
-
const keys = Array.from(this.cache.keys());
|
|
64
|
-
keys.forEach((key) => {
|
|
65
|
-
if (key.includes(pattern)) {
|
|
66
|
-
this.cache.delete(key);
|
|
67
|
-
}
|
|
68
|
-
});
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/**
|
|
72
|
-
* Invalidate specific cache key
|
|
73
|
-
* @param key - Cache key to invalidate
|
|
74
|
-
*/
|
|
75
|
-
invalidateKey(key: string): void {
|
|
76
|
-
this.cache.delete(key);
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
/**
|
|
80
|
-
* Clear all cache entries
|
|
81
|
-
*/
|
|
82
|
-
clear(): void {
|
|
83
|
-
this.cache.clear();
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
/**
|
|
87
|
-
* Get cache statistics
|
|
88
|
-
*/
|
|
89
|
-
getStats() {
|
|
90
|
-
return {
|
|
91
|
-
size: this.cache.size,
|
|
92
|
-
keys: Array.from(this.cache.keys()),
|
|
93
|
-
};
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
// Export singleton instance
|
|
98
|
-
export const memoryCache = new MemoryCache();
|
|
@@ -1,102 +0,0 @@
|
|
|
1
|
-
import { RATE_LIMITS } from '@/lib/supabase/constants';
|
|
2
|
-
|
|
3
|
-
interface RateLimitEntry {
|
|
4
|
-
tokens: number;
|
|
5
|
-
lastRefill: number;
|
|
6
|
-
}
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* Token Bucket Rate Limiter
|
|
10
|
-
* Implements the token bucket algorithm for rate limiting
|
|
11
|
-
*/
|
|
12
|
-
class RateLimiter {
|
|
13
|
-
private buckets: Map<string, RateLimitEntry> = new Map();
|
|
14
|
-
private maxTokens: number;
|
|
15
|
-
private refillRate: number; // tokens per second
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* @param maxTokens - Maximum number of tokens in the bucket
|
|
19
|
-
* @param refillRate - Number of tokens added per second
|
|
20
|
-
*/
|
|
21
|
-
constructor(maxTokens: number, refillRate: number) {
|
|
22
|
-
this.maxTokens = maxTokens;
|
|
23
|
-
this.refillRate = refillRate;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* Check if request is allowed under rate limit
|
|
28
|
-
* @param key - Unique identifier for the rate limit bucket (e.g., user ID, IP, endpoint)
|
|
29
|
-
* @returns true if request is allowed, false if rate limited
|
|
30
|
-
*/
|
|
31
|
-
async checkLimit(key: string): Promise<boolean> {
|
|
32
|
-
const now = Date.now();
|
|
33
|
-
let bucket = this.buckets.get(key);
|
|
34
|
-
|
|
35
|
-
// Initialize bucket if it doesn't exist
|
|
36
|
-
if (!bucket) {
|
|
37
|
-
bucket = {
|
|
38
|
-
tokens: this.maxTokens - 1,
|
|
39
|
-
lastRefill: now,
|
|
40
|
-
};
|
|
41
|
-
this.buckets.set(key, bucket);
|
|
42
|
-
return true;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
// Refill tokens based on time passed
|
|
46
|
-
const timePassed = (now - bucket.lastRefill) / 1000; // in seconds
|
|
47
|
-
const tokensToAdd = timePassed * this.refillRate;
|
|
48
|
-
bucket.tokens = Math.min(this.maxTokens, bucket.tokens + tokensToAdd);
|
|
49
|
-
bucket.lastRefill = now;
|
|
50
|
-
|
|
51
|
-
// Check if we have tokens available
|
|
52
|
-
if (bucket.tokens >= 1) {
|
|
53
|
-
bucket.tokens -= 1;
|
|
54
|
-
return true;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
// Rate limit exceeded
|
|
58
|
-
return false;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
* Get remaining tokens for a key
|
|
63
|
-
* @param key - Rate limit bucket key
|
|
64
|
-
*/
|
|
65
|
-
getRemainingTokens(key: string): number {
|
|
66
|
-
const bucket = this.buckets.get(key);
|
|
67
|
-
if (!bucket) {
|
|
68
|
-
return this.maxTokens;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
const now = Date.now();
|
|
72
|
-
const timePassed = (now - bucket.lastRefill) / 1000;
|
|
73
|
-
const tokensToAdd = timePassed * this.refillRate;
|
|
74
|
-
return Math.min(this.maxTokens, bucket.tokens + tokensToAdd);
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
/**
|
|
78
|
-
* Reset rate limit for a specific key
|
|
79
|
-
* @param key - Rate limit bucket key
|
|
80
|
-
*/
|
|
81
|
-
reset(key: string): void {
|
|
82
|
-
this.buckets.delete(key);
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
/**
|
|
86
|
-
* Clear all rate limit buckets
|
|
87
|
-
*/
|
|
88
|
-
clearAll(): void {
|
|
89
|
-
this.buckets.clear();
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
// Global rate limiters
|
|
94
|
-
export const perMinuteLimiter = new RateLimiter(
|
|
95
|
-
RATE_LIMITS.REQUESTS_PER_MINUTE,
|
|
96
|
-
RATE_LIMITS.REQUESTS_PER_MINUTE / 60 // tokens per second
|
|
97
|
-
);
|
|
98
|
-
|
|
99
|
-
export const perHourLimiter = new RateLimiter(
|
|
100
|
-
RATE_LIMITS.REQUESTS_PER_HOUR,
|
|
101
|
-
RATE_LIMITS.REQUESTS_PER_HOUR / 3600 // tokens per second
|
|
102
|
-
);
|