From 1e94c7ff0bb7ffd9cae2b6bbc4d5b0b2e6981b8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?essp=C3=ADndola?= Date: Tue, 16 Sep 2025 10:48:31 -0300 Subject: [PATCH 01/35] Add .gitkeep files to OCR and back directories --- frontend/backend/OCR/.gitkeep | 0 frontend/backend/back/.gitkeep | 0 2 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 frontend/backend/OCR/.gitkeep create mode 100644 frontend/backend/back/.gitkeep diff --git a/frontend/backend/OCR/.gitkeep b/frontend/backend/OCR/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/frontend/backend/back/.gitkeep b/frontend/backend/back/.gitkeep new file mode 100644 index 00000000..e69de29b From c90a77f9f5268d50ae26c923f0810ec2dd22de3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?essp=C3=ADndola?= Date: Tue, 16 Sep 2025 11:37:25 -0300 Subject: [PATCH 02/35] Implement OCR API with Gemini integration and add image processing functionality --- frontend/backend/OCR/client.ts | 5 ++ frontend/backend/OCR/handlers.ts | 112 ++++++++++++++++++++++++++++++ frontend/package.json | 1 + frontend/pnpm-lock.yaml | 9 +++ frontend/src/app/api/ocr/route.ts | 63 +++++++++++++++++ 5 files changed, 190 insertions(+) create mode 100644 frontend/backend/OCR/client.ts create mode 100644 frontend/backend/OCR/handlers.ts create mode 100644 frontend/src/app/api/ocr/route.ts diff --git a/frontend/backend/OCR/client.ts b/frontend/backend/OCR/client.ts new file mode 100644 index 00000000..f5d4ae5e --- /dev/null +++ b/frontend/backend/OCR/client.ts @@ -0,0 +1,5 @@ +import * as genai from '@google/generative-ai' + +export function createGeminiClient(apiKey: string) { + return new genai.GoogleGenerativeAI(apiKey) +} diff --git a/frontend/backend/OCR/handlers.ts b/frontend/backend/OCR/handlers.ts new file mode 100644 index 00000000..73559219 --- /dev/null +++ b/frontend/backend/OCR/handlers.ts @@ -0,0 +1,112 @@ +import { createGeminiClient } from './client'; + +const RATE_LIMIT_SECONDS = 300; +const userLastRequest: Map = new Map(); + +export type OCRResponse = { + text: string; + confidence?: number; + language?: string; + products?: Array>; +}; + +export function parseAuthHeader(authHeader: string | null | undefined) { + if (!authHeader) return 'anonymous'; + return authHeader.startsWith('Bearer ') + ? authHeader.replace('Bearer ', '') + : authHeader; +} + +export const ALLOWED_TYPES = [ + 'image/png', + 'image/jpg', + 'image/jpeg', + 'image/webp', + 'application/pdf', +]; + +export async function processImageFile(params: { + file: File; + authHeader: string | null | undefined; + env: any; +}): Promise { + const { file, authHeader, env } = params; + const userId = parseAuthHeader(authHeader); + const now = Date.now() / 1000; + const last = userLastRequest.get(userId) ?? 0; + if (now - last < RATE_LIMIT_SECONDS) + throw { status: 429, message: 'rate_limited' }; + userLastRequest.set(userId, now); + + if (!ALLOWED_TYPES.includes(file.type)) + throw { status: 400, message: 'invalid_type' }; + + const apiKey = env.GEMINI_API_KEY; + if (!apiKey) throw { status: 500, message: 'GEMINI_API_KEY not configured' }; + + const client = createGeminiClient(apiKey); + + const arrayBuffer = await file.arrayBuffer(); + const uint8 = new Uint8Array(arrayBuffer); + const base64Data = Buffer.from(uint8).toString('base64'); + + const prompt = env.OCR_PROMPT || ''; + + const parts = [ + { text: prompt }, + { inlineData: { mimeType: file.type, data: base64Data } }, + ]; + + const model = client.getGenerativeModel({ model: 'gemini-1.5-flash' }); + const result = await model.generateContent(parts); + const response = result.response; + + let extractedText = response.text(); + + extractedText = extractedText.trim(); + if (extractedText.startsWith('```json')) + extractedText = extractedText.slice(7); + if (extractedText.endsWith('```')) extractedText = extractedText.slice(0, -3); + extractedText = extractedText.trim(); + + try { + const parsed = JSON.parse(extractedText); + let products = null; + if (Array.isArray(parsed)) products = parsed; + else if (parsed && typeof parsed === 'object') { + if ('productos' in parsed) products = (parsed as any).productos; + else products = [parsed]; + } + return { text: extractedText, confidence: 0.95, language: 'es', products }; + } catch (e) { + return { text: extractedText, confidence: 0.95, language: 'es' }; + } +} + +export async function sendBulk(params: { + data: any; + authHeader: string | null | undefined; + env: any; +}) { + const { data, authHeader, env } = params; + const headers: Record = { + 'Content-Type': 'application/json', + }; + if (authHeader) { + const token = authHeader.startsWith('Bearer ') + ? authHeader.replace('Bearer ', '') + : authHeader; + headers['Authorization'] = `Bearer ${token}`; + } + const res = await fetch(`/api/products/bulk`, { + method: 'POST', + headers, + body: JSON.stringify(data), + }); + const text = await res.text(); + try { + return { status: res.status, ok: res.ok, body: JSON.parse(text) }; + } catch { + return { status: res.status, ok: res.ok, body: text }; + } +} diff --git a/frontend/package.json b/frontend/package.json index aed25987..ee06d7c1 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,6 +9,7 @@ "lint": "eslint" }, "dependencies": { + "@google/generative-ai": "^0.24.1", "@polar-sh/nextjs": "^0.4.6", "@polar-sh/sdk": "^0.34.16", "@radix-ui/react-accordion": "^1.2.12", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 5daae71b..7723c962 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@google/generative-ai': + specifier: ^0.24.1 + version: 0.24.1 '@polar-sh/nextjs': specifier: ^0.4.6 version: 0.4.6(next@15.5.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)) @@ -302,6 +305,10 @@ packages: '@floating-ui/utils@0.2.10': resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==} + '@google/generative-ai@0.24.1': + resolution: {integrity: sha512-MqO+MLfM6kjxcKoy0p1wRzG3b4ZZXtPI+z2IE26UogS2Cm/XHO+7gGRBh6gcJsOiIVoH93UwKvW4HdgiOZCy9Q==} + engines: {node: '>=18.0.0'} + '@humanfs/core@0.19.1': resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} engines: {node: '>=18.18.0'} @@ -3934,6 +3941,8 @@ snapshots: '@floating-ui/utils@0.2.10': {} + '@google/generative-ai@0.24.1': {} + '@humanfs/core@0.19.1': {} '@humanfs/node@0.16.6': diff --git a/frontend/src/app/api/ocr/route.ts b/frontend/src/app/api/ocr/route.ts new file mode 100644 index 00000000..e3381c31 --- /dev/null +++ b/frontend/src/app/api/ocr/route.ts @@ -0,0 +1,63 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { processImageFile, sendBulk } from '@/../backend/OCR/handlers'; + +export async function GET() { + return NextResponse.json({ message: 'ZatoBox OCR API con Gemini' }); +} + +export async function POST(req: NextRequest) { + const url = new URL(req.url); + const pathname = url.pathname.replace('/api/ocr', '') || '/ocr'; + + if (pathname === '/' || pathname === '') + return NextResponse.json({ message: 'ZatoBox OCR API with Gemini' }); + + try { + if (pathname === '/ocr') { + const contentType = req.headers.get('content-type') || ''; + if (!contentType.includes('multipart/form-data')) + return NextResponse.json( + { detail: 'Content-Type should be multipart/form-data' }, + { status: 400 } + ); + const form = await req.formData(); + const file = form.get('file') as File | null; + if (!file) + return NextResponse.json( + { detail: 'No file found in form-data' }, + { status: 400 } + ); + const authHeader = req.headers.get('authorization'); + const result = await processImageFile({ + file, + authHeader, + env: process.env, + }); + return NextResponse.json(result); + } + + if (pathname === '/bulk') { + const body = await req.json(); + const authHeader = req.headers.get('authorization'); + const out = await sendBulk({ data: body, authHeader, env: process.env }); + return NextResponse.json(out, { status: out.status }); + } + + return NextResponse.json({ error: 'Not found' }, { status: 404 }); + } catch (err: any) { + if (err?.status === 429) + return NextResponse.json( + { detail: 'Too many requests. Please wait a moment and try again.' }, + { status: 429 } + ); + if (err?.status === 400) + return NextResponse.json( + { detail: 'Invalid file type' }, + { status: 400 } + ); + return NextResponse.json( + { detail: `Error: ${err?.message ?? String(err)}` }, + { status: 500 } + ); + } +} From 21d2f765cc3570d5e8854a68a62769aff6cb2b16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?essp=C3=ADndola?= Date: Tue, 16 Sep 2025 12:35:35 -0300 Subject: [PATCH 03/35] Add layout management functionality with CRUD operations and API routes --- frontend/backend/back/layout/models.ts | 24 +++++ frontend/backend/back/layout/repository.ts | 84 +++++++++++++++++ frontend/backend/back/layout/service.ts | 48 ++++++++++ frontend/src/app/api/layouts/route.ts | 105 +++++++++++++++++++++ 4 files changed, 261 insertions(+) create mode 100644 frontend/backend/back/layout/models.ts create mode 100644 frontend/backend/back/layout/repository.ts create mode 100644 frontend/backend/back/layout/service.ts create mode 100644 frontend/src/app/api/layouts/route.ts diff --git a/frontend/backend/back/layout/models.ts b/frontend/backend/back/layout/models.ts new file mode 100644 index 00000000..a5b7cd7f --- /dev/null +++ b/frontend/backend/back/layout/models.ts @@ -0,0 +1,24 @@ +export interface CreateLayoutRequest { + slug: string; + inventory_id: string; + hero_title?: string | null; + web_description?: string | null; + social_links?: Record | null; +} + +export interface UpdateLayoutRequest { + hero_title?: string | null; + web_description?: string | null; + social_links?: Record | null; +} + +export interface LayoutResponse { + slug: string; + owner_id: string; + inventory_id: string; + hero_title?: string | null; + web_description?: string | null; + social_links?: Record | null; + created_at?: string | null; + last_updated?: string | null; +} diff --git a/frontend/backend/back/layout/repository.ts b/frontend/backend/back/layout/repository.ts new file mode 100644 index 00000000..a49cb650 --- /dev/null +++ b/frontend/backend/back/layout/repository.ts @@ -0,0 +1,84 @@ +import { createClient } from '@supabase/supabase-js'; + +function getSupabase() { + const url = process.env.NEXT_PUBLIC_SUPABASE_URL || process.env.SUPABASE_URL; + const key = + process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY || + process.env.SUPABASE_SERVICE_KEY; + if (!url || !key) throw new Error('Supabase not configured'); + return createClient(url, key); +} + +export class LayoutRepository { + supabase: any; + table = 'layouts'; + constructor() { + this.supabase = getSupabase(); + } + + async createLayout(payload: any) { + const { data, error } = await this.supabase + .from(this.table) + .insert(payload) + .select() + .single(); + if (error) throw error; + return data; + } + + async updateLayout(slug: string, updates: any) { + const { data, error } = await this.supabase + .from(this.table) + .update(updates) + .eq('slug', slug) + .select() + .single(); + if (error) throw error; + return data; + } + + async findAll() { + const { data, error } = await this.supabase.from(this.table).select('*'); + if (error) throw error; + return data || []; + } + + async findBySlug(slug: string) { + const { data, error } = await this.supabase + .from(this.table) + .select('*') + .eq('slug', slug) + .single(); + if (error) return null; + return data; + } + + async findByOwner(owner_id: string) { + const { data, error } = await this.supabase + .from(this.table) + .select('*') + .eq('owner_id', owner_id); + if (error) throw error; + return data || []; + } + + async findByInventory(inventory_id: string) { + const { data, error } = await this.supabase + .from(this.table) + .select('*') + .eq('inventory_id', inventory_id); + if (error) throw error; + return data || []; + } + + async deleteLayout(slug: string) { + const { data, error } = await this.supabase + .from(this.table) + .delete() + .eq('slug', slug) + .select() + .single(); + if (error) throw error; + return data; + } +} diff --git a/frontend/backend/back/layout/service.ts b/frontend/backend/back/layout/service.ts new file mode 100644 index 00000000..d486fad7 --- /dev/null +++ b/frontend/backend/back/layout/service.ts @@ -0,0 +1,48 @@ +import { LayoutRepository } from './repository'; + +export class LayoutService { + repo: LayoutRepository; + constructor(repo?: LayoutRepository) { + this.repo = repo || new LayoutRepository(); + } + + async createLayout(layoutData: any, owner_id: string) { + return this.repo.createLayout({ ...layoutData, owner_id }); + } + + async listLayouts() { + return this.repo.findAll(); + } + + async getLayout(layout_slug: string) { + if (!layout_slug || typeof layout_slug !== 'string') + throw new Error('Invalid layout slug'); + const layout = await this.repo.findBySlug(layout_slug); + if (!layout) throw new Error('Layout not found'); + return layout; + } + + async updateLayout(layout_slug: string, updates: any) { + const allowed = ['hero_title', 'web_description', 'social_links']; + for (const k of Object.keys(updates)) { + if (!allowed.includes(k)) throw new Error(`Invalid field: ${k}`); + } + for (const fld of ['hero_title', 'web_description', 'social_links']) { + const val = updates[fld]; + if (val === '' || val == null) delete updates[fld]; + } + return this.repo.updateLayout(layout_slug, updates); + } + + async deleteLayout(layout_slug: string) { + return this.repo.deleteLayout(layout_slug); + } + + async listLayoutsByOwner(owner_id: string) { + return this.repo.findByOwner(owner_id); + } + + async listLayoutsByInventory(inventory_id: string) { + return this.repo.findByInventory(inventory_id); + } +} diff --git a/frontend/src/app/api/layouts/route.ts b/frontend/src/app/api/layouts/route.ts new file mode 100644 index 00000000..963c3cb2 --- /dev/null +++ b/frontend/src/app/api/layouts/route.ts @@ -0,0 +1,105 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { LayoutService } from '@/../backend/back/layout/service'; + +const service = new LayoutService(); + +export async function POST(req: NextRequest) { + try { + const body = await req.json(); + const owner = req.headers.get('x-user-id') || 'anonymous'; + const layout = await service.createLayout(body, owner); + return NextResponse.json({ + success: true, + message: 'Layout created successfully', + layout, + }); + } catch (err: any) { + return NextResponse.json( + { success: false, message: String(err?.message ?? err) }, + { status: 500 } + ); + } +} + +export async function GET(req: NextRequest) { + const url = new URL(req.url); + const pathname = url.pathname.replace('/api/layouts', '') || '/'; + try { + if (pathname === '/' || pathname === '') { + const layouts = await service.listLayouts(); + return NextResponse.json({ success: true, layouts }); + } + const parts = pathname.split('/').filter(Boolean); + if (parts[0] === 'owner' && parts[1]) { + const layouts = await service.listLayoutsByOwner(parts[1]); + return NextResponse.json({ success: true, layouts }); + } + if (parts[0] === 'inventory' && parts[1]) { + const layouts = await service.listLayoutsByInventory(parts[1]); + return NextResponse.json({ success: true, layouts }); + } + if (parts[0]) { + const layout = await service.getLayout(parts[0]); + return NextResponse.json({ + success: true, + message: 'Layout found', + layout, + }); + } + return NextResponse.json({ error: 'Not found' }, { status: 404 }); + } catch (err: any) { + return NextResponse.json( + { success: false, message: String(err?.message ?? err) }, + { status: 500 } + ); + } +} + +export async function PUT(req: NextRequest) { + const url = new URL(req.url); + const pathname = url.pathname.replace('/api/layouts', '') || '/'; + const parts = pathname.split('/').filter(Boolean); + if (!parts[0]) + return NextResponse.json( + { success: false, message: 'Missing layout slug' }, + { status: 400 } + ); + try { + const updates = await req.json(); + const updated = await service.updateLayout(parts[0], updates); + return NextResponse.json({ + success: true, + message: 'Layout updated successfully', + layout: updated, + }); + } catch (err: any) { + return NextResponse.json( + { success: false, message: String(err?.message ?? err) }, + { status: 500 } + ); + } +} + +export async function DELETE(req: NextRequest) { + const url = new URL(req.url); + const pathname = url.pathname.replace('/api/layouts', '') || '/'; + const parts = pathname.split('/').filter(Boolean); + if (!parts[0]) + return NextResponse.json( + { success: false, message: 'Missing layout slug' }, + { status: 400 } + ); + try { + const deleted = await service.deleteLayout(parts[0]); + return NextResponse.json({ + success: true, + message: 'Layout deleted successfully', + layout: deleted, + }); + } catch (err: any) { + return NextResponse.json( + { success: false, message: String(err?.message ?? err) }, + { status: 500 } + ); + } +} From 19314fc245af30b4c28413e23ddd3729444698ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?essp=C3=ADndola?= Date: Tue, 16 Sep 2025 13:06:22 -0300 Subject: [PATCH 04/35] Add category management functionality with repository and service layers --- frontend/backend/back/category/models.ts | 16 +++++++++ frontend/backend/back/category/repository.ts | 23 +++++++++++++ frontend/backend/back/category/service.ts | 18 ++++++++++ frontend/backend/back/layout/repository.ts | 36 ++++++++------------ frontend/src/app/api/categories/route.ts | 26 ++++++++++++++ 5 files changed, 98 insertions(+), 21 deletions(-) create mode 100644 frontend/backend/back/category/models.ts create mode 100644 frontend/backend/back/category/repository.ts create mode 100644 frontend/backend/back/category/service.ts create mode 100644 frontend/src/app/api/categories/route.ts diff --git a/frontend/backend/back/category/models.ts b/frontend/backend/back/category/models.ts new file mode 100644 index 00000000..a5de36c2 --- /dev/null +++ b/frontend/backend/back/category/models.ts @@ -0,0 +1,16 @@ +export interface Category { + id: string; + name: string; + created_at?: string | null; + last_updated?: string | null; +} + +export interface CategoryResponse { + success: boolean; + category: Category; +} + +export interface CategoriesResponse { + success: boolean; + categories: Category[]; +} diff --git a/frontend/backend/back/category/repository.ts b/frontend/backend/back/category/repository.ts new file mode 100644 index 00000000..de6d489f --- /dev/null +++ b/frontend/backend/back/category/repository.ts @@ -0,0 +1,23 @@ +import { createClient } from '@/utils/supabase/server'; + +export class CategoryRepository { + table = 'categories'; + + async list() { + const supabase = await createClient(); + const { data, error } = await supabase.from(this.table).select('*'); + if (error) throw error; + return data || []; + } + + async get(category_id: string) { + const supabase = await createClient(); + const { data, error } = await supabase + .from(this.table) + .select('*') + .eq('id', category_id) + .single(); + if (error) return null; + return data; + } +} diff --git a/frontend/backend/back/category/service.ts b/frontend/backend/back/category/service.ts new file mode 100644 index 00000000..d131813e --- /dev/null +++ b/frontend/backend/back/category/service.ts @@ -0,0 +1,18 @@ +import { CategoryRepository } from './repository'; + +export class CategoryService { + repo: CategoryRepository; + constructor(repo?: CategoryRepository) { + this.repo = repo || new CategoryRepository(); + } + + async listCategories() { + return this.repo.list(); + } + + async getCategory(category_id: string) { + const category = await this.repo.get(category_id); + if (!category) throw new Error('Category not found'); + return category; + } +} diff --git a/frontend/backend/back/layout/repository.ts b/frontend/backend/back/layout/repository.ts index a49cb650..d220d71f 100644 --- a/frontend/backend/back/layout/repository.ts +++ b/frontend/backend/back/layout/repository.ts @@ -1,23 +1,11 @@ -import { createClient } from '@supabase/supabase-js'; - -function getSupabase() { - const url = process.env.NEXT_PUBLIC_SUPABASE_URL || process.env.SUPABASE_URL; - const key = - process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY || - process.env.SUPABASE_SERVICE_KEY; - if (!url || !key) throw new Error('Supabase not configured'); - return createClient(url, key); -} +import { createClient } from '@/utils/supabase/server'; export class LayoutRepository { - supabase: any; table = 'layouts'; - constructor() { - this.supabase = getSupabase(); - } async createLayout(payload: any) { - const { data, error } = await this.supabase + const supabase = await createClient(); + const { data, error } = await supabase .from(this.table) .insert(payload) .select() @@ -27,7 +15,8 @@ export class LayoutRepository { } async updateLayout(slug: string, updates: any) { - const { data, error } = await this.supabase + const supabase = await createClient(); + const { data, error } = await supabase .from(this.table) .update(updates) .eq('slug', slug) @@ -38,13 +27,15 @@ export class LayoutRepository { } async findAll() { - const { data, error } = await this.supabase.from(this.table).select('*'); + const supabase = await createClient(); + const { data, error } = await supabase.from(this.table).select('*'); if (error) throw error; return data || []; } async findBySlug(slug: string) { - const { data, error } = await this.supabase + const supabase = await createClient(); + const { data, error } = await supabase .from(this.table) .select('*') .eq('slug', slug) @@ -54,7 +45,8 @@ export class LayoutRepository { } async findByOwner(owner_id: string) { - const { data, error } = await this.supabase + const supabase = await createClient(); + const { data, error } = await supabase .from(this.table) .select('*') .eq('owner_id', owner_id); @@ -63,7 +55,8 @@ export class LayoutRepository { } async findByInventory(inventory_id: string) { - const { data, error } = await this.supabase + const supabase = await createClient(); + const { data, error } = await supabase .from(this.table) .select('*') .eq('inventory_id', inventory_id); @@ -72,7 +65,8 @@ export class LayoutRepository { } async deleteLayout(slug: string) { - const { data, error } = await this.supabase + const supabase = await createClient(); + const { data, error } = await supabase .from(this.table) .delete() .eq('slug', slug) diff --git a/frontend/src/app/api/categories/route.ts b/frontend/src/app/api/categories/route.ts new file mode 100644 index 00000000..41eef479 --- /dev/null +++ b/frontend/src/app/api/categories/route.ts @@ -0,0 +1,26 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { CategoryService } from '@/../backend/back/category/service'; + +const service = new CategoryService(); + +export async function GET(req: NextRequest) { + const url = new URL(req.url); + const pathname = url.pathname.replace('/api/categories', '') || '/'; + try { + if (pathname === '/' || pathname === '') { + const categories = await service.listCategories(); + return NextResponse.json({ success: true, categories }); + } + const parts = pathname.split('/').filter(Boolean); + if (parts[0]) { + const category = await service.getCategory(parts[0]); + return NextResponse.json({ success: true, category }); + } + return NextResponse.json({ error: 'Not found' }, { status: 404 }); + } catch (err: any) { + return NextResponse.json( + { success: false, message: String(err?.message ?? err) }, + { status: 500 } + ); + } +} From 21726eb6199f454a93e9d7f1b4bbfae99b25636e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?essp=C3=ADndola?= Date: Tue, 16 Sep 2025 14:02:26 -0300 Subject: [PATCH 05/35] Implement inventory management with models, repository, service, and API routes --- frontend/backend/back/inventory/models.ts | 21 +++ frontend/backend/back/inventory/repository.ts | 165 ++++++++++++++++++ frontend/backend/back/inventory/service.ts | 58 ++++++ frontend/src/app/api/inventory/route.ts | 85 +++++++++ 4 files changed, 329 insertions(+) create mode 100644 frontend/backend/back/inventory/models.ts create mode 100644 frontend/backend/back/inventory/repository.ts create mode 100644 frontend/backend/back/inventory/service.ts create mode 100644 frontend/src/app/api/inventory/route.ts diff --git a/frontend/backend/back/inventory/models.ts b/frontend/backend/back/inventory/models.ts new file mode 100644 index 00000000..0e116769 --- /dev/null +++ b/frontend/backend/back/inventory/models.ts @@ -0,0 +1,21 @@ +export interface Product { + id: string; + name?: string; + status?: string; +} + +export interface Inventory { + id?: string | null; + inventory_owner?: string | null; + products: Product[]; + created_at?: string | null; + last_updated?: string | null; +} + +export interface InventoryResponse { + success: boolean; + inventory?: Inventory | null; + total_products?: number; + total_stock?: number | null; + low_stock_count?: number | null; +} diff --git a/frontend/backend/back/inventory/repository.ts b/frontend/backend/back/inventory/repository.ts new file mode 100644 index 00000000..6e2c4024 --- /dev/null +++ b/frontend/backend/back/inventory/repository.ts @@ -0,0 +1,165 @@ +import { createClient } from '@/utils/supabase/server'; +import type { Inventory, Product } from './models'; + +export class InventoryRepository { + table = 'inventory'; + + async getInventoryByOwner(owner_id: string): Promise { + const supabase = await createClient(); + const resp = await supabase + .from(this.table) + .select('*') + .eq('inventory_owner', owner_id) + .limit(1); + const data: any = + resp.data && resp.data.length ? resp.data[0] : resp.data || null; + if (!data) return null; + let products = data.products || []; + if (products && typeof products === 'object' && !Array.isArray(products)) { + try { + products = Object.values(products as Record); + } catch { + products = []; + } + } + data.products = products; + return data as Inventory; + } + + async createInventory(owner_id: string): Promise { + const supabase = await createClient(); + const payload = { inventory_owner: owner_id, products: [] }; + const res = await supabase + .from(this.table) + .insert(payload) + .select() + .single(); + const data: any = res.data; + if (!data) throw new Error('Error creating inventory'); + return data as Inventory; + } + + async getOrCreateInventory(owner_id: string): Promise { + const inv = await this.getInventoryByOwner(owner_id); + if (inv) return inv; + return this.createInventory(owner_id); + } + + async getInventoryItems(owner_id: string): Promise { + const inv = await this.getInventoryByOwner(owner_id); + if (!inv || !inv.products) return []; + return inv.products as Product[]; + } + + async getInventoryItem( + owner_id: string, + product_id: string + ): Promise { + const inv = await this.getInventoryByOwner(owner_id); + if (!inv || !inv.products) return null; + for (const p of inv.products) { + const pid = + typeof p === 'object' && 'id' in p ? (p as any).id : undefined; + if (String(pid) === String(product_id)) return p as Product; + } + return null; + } + + async updateInventoryItem( + owner_id: string, + product_id: string, + updates: Record + ): Promise { + const supabase = await createClient(); + const inv = await this.getOrCreateInventory(owner_id); + const products = inv.products || []; + let foundIndex = -1; + for (let i = 0; i < products.length; i++) { + const p = products[i]; + const pid = + typeof p === 'object' && 'id' in p ? (p as any).id : undefined; + if (String(pid) === String(product_id)) { + foundIndex = i; + break; + } + } + let currentProduct: any = null; + if (foundIndex === -1) { + const productResp = await supabase + .from('products') + .select('id,name,status') + .eq('id', product_id) + .limit(1); + const productData: any = + productResp.data && productResp.data.length + ? productResp.data[0] + : null; + if (!productData) throw new Error('Product not found'); + const newProduct = { + id: productData.id, + name: productData.name, + status: productData.status || 'active', + }; + products.push(newProduct); + currentProduct = newProduct; + } else { + const existing = products[foundIndex]; + currentProduct = typeof existing === 'object' ? existing : existing; + for (const [k, v] of Object.entries(updates)) { + if (k === 'name' || k === 'status') currentProduct[k] = v; + } + products[foundIndex] = currentProduct; + } + const serializable = products.map((p) => (typeof p === 'object' ? p : p)); + const resp = await supabase + .from(this.table) + .update({ products: serializable }) + .eq('inventory_owner', owner_id) + .select(); + const data: any = resp.data; + if (!data) throw new Error('Error updating inventory'); + return currentProduct as Product; + } + + async removeInventoryItem( + owner_id: string, + product_id: string + ): Promise { + const supabase = await createClient(); + const inv = await this.getInventoryByOwner(owner_id); + if (!inv || !inv.products) return false; + const products = inv.products; + const newProducts = products.filter( + (p) => String((p as any).id) !== String(product_id) + ); + const resp = await supabase + .from(this.table) + .update({ products: newProducts }) + .eq('inventory_owner', owner_id) + .select(); + const data: any = resp.data; + return data != null; + } + + async getInventorySummary( + owner_id: string + ): Promise<{ + total_products: number; + total_stock: null; + low_stock_count: null; + }> { + const items = await this.getInventoryItems(owner_id); + return { + total_products: items.length, + total_stock: null, + low_stock_count: null, + }; + } + + async checkLowStock( + owner_id: string, + threshold: number = 0 + ): Promise { + return []; + } +} diff --git a/frontend/backend/back/inventory/service.ts b/frontend/backend/back/inventory/service.ts new file mode 100644 index 00000000..bb088917 --- /dev/null +++ b/frontend/backend/back/inventory/service.ts @@ -0,0 +1,58 @@ +import { InventoryRepository } from './repository'; +import type { Product, InventoryResponse } from './models'; + +export class InventoryService { + repo: InventoryRepository; + + constructor() { + this.repo = new InventoryRepository(); + } + + async getInventory(user_id: string): Promise { + return this.getInventoryByUser(user_id); + } + + async getInventoryByUser(user_id: string): Promise { + return this.repo.getInventoryItems(user_id); + } + + async updateStock(): Promise { + throw new Error('Updating stock on inventory.jsonb is not supported'); + } + + async getInventoryItem( + user_id: string, + product_id: string + ): Promise { + return this.repo.getInventoryItem(user_id, product_id); + } + + async checkLowStock( + user_id: string, + min_threshold: number = 0 + ): Promise { + return this.repo.checkLowStock(user_id, min_threshold); + } + + async getInventorySummary(user_id: string) { + return this.repo.getInventorySummary(user_id); + } + + async getInventoryResponse(user_id: string): Promise { + const items = await this.getInventoryByUser(user_id); + const summary = await this.getInventorySummary(user_id); + return { + success: true, + inventory: { + id: null, + inventory_owner: user_id, + products: items, + created_at: null, + last_updated: null, + }, + total_products: summary.total_products, + total_stock: summary.total_stock, + low_stock_count: summary.low_stock_count, + }; + } +} diff --git a/frontend/src/app/api/inventory/route.ts b/frontend/src/app/api/inventory/route.ts new file mode 100644 index 00000000..a3523a3a --- /dev/null +++ b/frontend/src/app/api/inventory/route.ts @@ -0,0 +1,85 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { InventoryService } from '@/../backend/back/inventory/service'; + +const service = new InventoryService(); + +export async function GET(req: NextRequest) { + const url = new URL(req.url); + const pathname = url.pathname.replace('/api/inventory', '') || '/'; + try { + if (pathname === '/' || pathname === '') { + const userId = req.headers.get('x-user-id') || 'anonymous'; + const inventory = await service.getInventoryResponse(userId); + return NextResponse.json(inventory); + } + const parts = pathname.split('/').filter(Boolean); + if (parts[0] === 'user') { + const userId = req.headers.get('x-user-id') || 'anonymous'; + const inventory = await service.getInventoryResponse(userId); + return NextResponse.json(inventory); + } + if (parts[0] === 'summary') { + const userId = req.headers.get('x-user-id') || 'anonymous'; + const summary = await service.getInventorySummary(userId); + return NextResponse.json({ success: true, summary }); + } + if (parts[0] === 'low-stock') { + const userId = req.headers.get('x-user-id') || 'anonymous'; + const q = url.searchParams.get('threshold') || '0'; + const threshold = parseInt(q, 10) || 0; + const items = await service.checkLowStock(userId, threshold); + return NextResponse.json({ success: true, low_stock_products: items }); + } + if (parts[0] === 'user' || parts[0] === '') { + const userId = req.headers.get('x-user-id') || 'anonymous'; + const inventory = await service.getInventoryResponse(userId); + return NextResponse.json(inventory); + } + if (parts[0]) { + const userId = req.headers.get('x-user-id') || 'anonymous'; + const item = await service.getInventoryItem(userId, parts[0]); + if (!item) + return NextResponse.json( + { success: false, message: 'Product not found in inventory' }, + { status: 404 } + ); + return NextResponse.json({ success: true, product: item }); + } + return NextResponse.json({ error: 'Not found' }, { status: 404 }); + } catch (err: any) { + return NextResponse.json( + { success: false, message: String(err?.message ?? err) }, + { status: 500 } + ); + } +} + +export async function PUT(req: NextRequest) { + try { + const url = new URL(req.url); + const parts = url.pathname + .replace('/api/inventory', '') + .split('/') + .filter(Boolean); + if (!parts[0]) + return NextResponse.json( + { success: false, message: 'Missing product id' }, + { status: 400 } + ); + const productId = parts[0]; + const body = await req.json(); + const quantity = body.quantity as number; + const reason = body.reason as string | undefined; + const userId = req.headers.get('x-user-id') || 'anonymous'; + await service.updateStock(); + return NextResponse.json( + { success: false, message: 'Updating stock not supported' }, + { status: 400 } + ); + } catch (err: any) { + return NextResponse.json( + { success: false, message: String(err?.message ?? err) }, + { status: 500 } + ); + } +} From b4c4225e3fcf1fac9352cebabb9da8a17574e55b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?essp=C3=ADndola?= Date: Tue, 16 Sep 2025 16:58:12 -0300 Subject: [PATCH 06/35] Implement product management with models, repository, service, and API routes --- frontend/backend/back/product/models.ts | 59 +++++++ frontend/backend/back/product/repository.ts | 117 +++++++++++++ frontend/backend/back/product/service.ts | 175 ++++++++++++++++++++ frontend/package.json | 1 + frontend/pnpm-lock.yaml | 22 +++ frontend/src/app/api/products/route.ts | 165 ++++++++++++++++++ frontend/src/utils/cloudinary.ts | 104 ++++++++++++ 7 files changed, 643 insertions(+) create mode 100644 frontend/backend/back/product/models.ts create mode 100644 frontend/backend/back/product/repository.ts create mode 100644 frontend/backend/back/product/service.ts create mode 100644 frontend/src/app/api/products/route.ts create mode 100644 frontend/src/utils/cloudinary.ts diff --git a/frontend/backend/back/product/models.ts b/frontend/backend/back/product/models.ts new file mode 100644 index 00000000..4934188f --- /dev/null +++ b/frontend/backend/back/product/models.ts @@ -0,0 +1,59 @@ +export interface CreateProductRequest { + name: string; + description?: string | null; + price: number; + stock: number; + unit?: string | null; + product_type?: string | null; + category_ids?: string[] | null; + sku?: string | null; + min_stock?: number | null; + status?: string | null; + weight?: number | null; + localization?: string | null; +} + +export interface UpdateProductRequest { + name?: string; + description?: string | null; + price?: number; + stock?: number; + unit?: string | null; + product_type?: string | null; + category_ids?: string[] | null; + sku?: string | null; + min_stock?: number | null; + status?: string | null; + weight?: number | null; + localization?: string | null; +} + +export interface Product { + id: string; + name: string; + description?: string | null; + price: number; + stock: number; + unit?: string | null; + product_type?: string | null; + category_ids?: string[] | null; + sku?: string | null; + min_stock?: number | null; + status?: string | null; + weight?: number | null; + localization?: string | null; + images?: string[]; + creator_id?: string | null; + created_at?: string | null; + last_updated?: string | null; +} + +export interface ProductResponse { + success: boolean; + product?: Product | null; +} + +export interface ProductsResponse { + success: boolean; + products: Product[]; +} diff --git a/frontend/backend/back/product/repository.ts b/frontend/backend/back/product/repository.ts new file mode 100644 index 00000000..191c25d5 --- /dev/null +++ b/frontend/backend/back/product/repository.ts @@ -0,0 +1,117 @@ +import { createClient } from '@/utils/supabase/server'; +import type { Product } from './models'; + +export class ProductRepository { + table = 'products'; + + async createProduct(payload: Record): Promise { + const supabase = await createClient(); + const res = await supabase + .from(this.table) + .insert(payload) + .select() + .single(); + const data: any = res.data; + if (!data) throw new Error('Error creating product'); + return data as Product; + } + + async updateProduct( + product_id: string, + updates: Record + ): Promise { + const supabase = await createClient(); + const resp = await supabase + .from(this.table) + .update(updates) + .eq('id', product_id) + .select() + .single(); + const data: any = resp.data; + if (!data) throw new Error('Product not found'); + return data as Product; + } + + async findAll(): Promise { + const supabase = await createClient(); + const { data } = await supabase.from(this.table).select('*'); + return (data || []) as Product[]; + } + + async findById(product_id: string): Promise { + const supabase = await createClient(); + const { data, error } = await supabase + .from(this.table) + .select('*') + .eq('id', product_id) + .limit(1); + if (error) return null; + if (!data || data.length === 0) return null; + return data[0] as Product; + } + + async deleteProduct(product_id: string): Promise { + const supabase = await createClient(); + const { data } = await supabase + .from(this.table) + .delete() + .eq('id', product_id) + .select() + .single(); + if (!data) throw new Error('Product not found'); + return data as Product; + } + + async findByCreator(creator_id: string): Promise { + const supabase = await createClient(); + const { data } = await supabase + .from(this.table) + .select('*') + .eq('creator_id', creator_id); + return (data || []) as Product[]; + } + + async findByName(name: string): Promise { + const supabase = await createClient(); + const { data } = await supabase + .from(this.table) + .select('*') + .ilike('name', name); + return (data || []) as Product[]; + } + + async addImages(product_id: string, new_images: string[]): Promise { + const prod = await this.findById(product_id); + if (!prod) throw new Error('Product not found'); + const current = prod.images || []; + const updated = current.concat(new_images); + if (updated.length > 4) throw new Error('Maximum 4 images allowed'); + return this.updateProduct(product_id, { images: updated }); + } + + async getImages(product_id: string): Promise { + const prod = await this.findById(product_id); + if (!prod) throw new Error('Product not found'); + return prod.images || []; + } + + async deleteImage(product_id: string, image_index: number): Promise { + const prod = await this.findById(product_id); + if (!prod) throw new Error('Product not found'); + const current = prod.images || []; + if (image_index < 0 || image_index >= current.length) + throw new Error('Invalid image index'); + const updated = current + .slice(0, image_index) + .concat(current.slice(image_index + 1)); + return this.updateProduct(product_id, { images: updated }); + } + + async updateImages( + product_id: string, + new_images: string[] + ): Promise { + if (new_images.length > 4) throw new Error('Maximum 4 images allowed'); + return this.updateProduct(product_id, { images: new_images }); + } +} diff --git a/frontend/backend/back/product/service.ts b/frontend/backend/back/product/service.ts new file mode 100644 index 00000000..910777a1 --- /dev/null +++ b/frontend/backend/back/product/service.ts @@ -0,0 +1,175 @@ +import { ProductRepository } from './repository'; +import type { + CreateProductRequest, + UpdateProductRequest, + Product, +} from './models'; + +const MAX_IMAGES = 4; + +export class ProductService { + repo: ProductRepository; + constructor(repo?: ProductRepository) { + this.repo = repo || new ProductRepository(); + } + + async createProduct( + product_data: CreateProductRequest, + creator_id: string + ): Promise { + const unit_val = (product_data as any).unit ?? product_data.unit; + const type_val = + (product_data as any).product_type ?? product_data.product_type; + const category_ids_val = (product_data as any).category_ids || []; + if (!Array.isArray(category_ids_val)) + throw new Error('category_ids must be a list'); + for (const cid of category_ids_val) { + if (typeof cid !== 'string' || !cid) + throw new Error('Invalid category id in category_ids'); + } + const initial_images: string[] = []; + if (initial_images.length > MAX_IMAGES) + throw new Error('Maximum 4 images allowed'); + return this.repo.createProduct({ + name: product_data.name, + description: product_data.description ?? null, + price: Number(product_data.price), + stock: Number(product_data.stock), + unit: unit_val, + product_type: type_val, + category_ids: category_ids_val, + sku: product_data.sku ?? null, + min_stock: Number((product_data as any).min_stock ?? 0), + status: (product_data as any).status ?? 'active', + weight: (product_data as any).weight ?? null, + localization: (product_data as any).localization ?? null, + creator_id: String(creator_id), + images: initial_images, + }); + } + + async listProducts(creator_id: string) { + return this.repo.findByCreator(creator_id); + } + + async searchByCategory(category: string) { + return this.repo.findByName(category); + } + + async searchByName(name: string) { + return this.repo.findByName(name); + } + + async getProduct(product_id: string) { + if (!product_id) throw new Error('Invalid product ID'); + const product = await this.repo.findById(product_id); + if (!product) throw new Error('Product not found'); + return product; + } + + async updateProduct( + product_id: string, + updates: Record, + user_timezone: string = 'UTC' + ) { + if (!product_id) throw new Error('Invalid product ID'); + const allowed_fields = [ + 'name', + 'description', + 'price', + 'stock', + 'category_ids', + 'images', + 'sku', + 'weight', + 'localization', + 'min_stock', + 'status', + 'product_type', + 'unit', + ]; + for (const field of Object.keys(updates)) { + if (!allowed_fields.includes(field)) + throw new Error(`Invalid field: ${field}`); + } + for (const fld of [ + 'product_type', + 'unit', + 'category_ids', + 'description', + 'localization', + 'sku', + 'status', + ]) { + const val = updates[fld]; + if (val === '' || val === null || val === undefined) delete updates[fld]; + } + if ('category_ids' in updates) { + if (!Array.isArray(updates['category_ids'])) + throw new Error('category_ids must be a list'); + const cleaned: string[] = []; + for (const cid of updates['category_ids']) { + if (typeof cid !== 'string' || !cid) + throw new Error('Invalid category id in category_ids'); + cleaned.push(cid); + } + updates['category_ids'] = cleaned; + } + if ('price' in updates) { + const v = Number(updates['price']); + if (Number.isNaN(v) || v <= 0) throw new Error('Invalid price value'); + updates['price'] = v; + } + if ('stock' in updates) { + const v = Number(updates['stock']); + if (!Number.isInteger(v) || v < 0) throw new Error('Invalid stock value'); + updates['stock'] = v; + } + if ('min_stock' in updates) { + const v = Number(updates['min_stock']); + if (!Number.isInteger(v) || v < 0) + throw new Error('Invalid min_stock value'); + updates['min_stock'] = v; + } + if ('weight' in updates) { + const v = Number(updates['weight']); + if (Number.isNaN(v) || v < 0) throw new Error('Invalid weight value'); + updates['weight'] = v; + } + return this.repo.updateProduct(product_id, updates); + } + + async addImages(product_id: string, new_images: string[]) { + if (!product_id) throw new Error('Invalid product ID'); + if (new_images.length > MAX_IMAGES) + throw new Error('Maximum 4 images allowed'); + const product = await this.repo.findById(product_id); + if (!product) throw new Error('Product not found'); + const current = product.images || []; + if (current.length + new_images.length > MAX_IMAGES) + throw new Error('Maximum 4 images allowed'); + return this.repo.addImages(product_id, new_images); + } + + async getImages(product_id: string) { + if (!product_id) throw new Error('Invalid product ID'); + return this.repo.getImages(product_id); + } + + async deleteImage(product_id: string, image_index: number) { + if (!product_id) throw new Error('Invalid product ID'); + return this.repo.deleteImage(product_id, image_index); + } + + async updateImages(product_id: string, new_images: string[]) { + if (!product_id) throw new Error('Invalid product ID'); + if (new_images.length > MAX_IMAGES) + throw new Error('Maximum 4 images allowed'); + return this.repo.updateImages(product_id, new_images); + } + + async deleteProduct(product_id: string) { + if (!product_id) throw new Error('Invalid product ID'); + return this.repo.deleteProduct(product_id); + } +} diff --git a/frontend/package.json b/frontend/package.json index ee06d7c1..1b950a29 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -43,6 +43,7 @@ "@supabase/supabase-js": "^2.56.0", "axios": "^1.7.7", "class-variance-authority": "^0.7.1", + "cloudinary": "^2.7.0", "clsx": "^2.1.1", "cmdk": "^1.1.1", "embla-carousel-react": "^8.6.0", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 7723c962..96fcf4ba 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -110,6 +110,9 @@ importers: class-variance-authority: specifier: ^0.7.1 version: 0.7.1 + cloudinary: + specifier: ^2.7.0 + version: 2.7.0 clsx: specifier: ^2.1.1 version: 2.1.1 @@ -2073,6 +2076,10 @@ packages: client-only@0.0.1: resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + cloudinary@2.7.0: + resolution: {integrity: sha512-qrqDn31+qkMCzKu1GfRpzPNAO86jchcNwEHCUiqvPHNSFqu7FTNF9FuAkBUyvM1CFFgFPu64NT0DyeREwLwK0w==} + engines: {node: '>=9'} + clsx@2.1.1: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} @@ -3189,6 +3196,14 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + q@1.5.1: + resolution: {integrity: sha512-kV/CThkXo6xyFEZUugw/+pIOywXcDbFYgSct5cT3gqlbkBE1SJdwy6UQoZvodiWF/ckQLZyDE/Bu1M6gVu5lVw==} + engines: {node: '>=0.6.0', teleport: '>=0.2.0'} + deprecated: |- + You or someone you depend on is using Q, the JavaScript Promise library that gave JavaScript developers strong feelings about promises. They can almost certainly migrate to the native JavaScript promise now. Thank you literally everyone for joining me in this bet against the odds. Be excellent to each other. + + (For a CapTP with native promises, see @endo/eventual-send and @endo/captp) + querystringify@2.2.0: resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==} @@ -5935,6 +5950,11 @@ snapshots: client-only@0.0.1: {} + cloudinary@2.7.0: + dependencies: + lodash: 4.17.21 + q: 1.5.1 + clsx@2.1.1: {} cmdk@1.1.1(@types/react-dom@19.1.7(@types/react@19.1.10))(@types/react@19.1.10)(react-dom@19.1.0(react@19.1.0))(react@19.1.0): @@ -7148,6 +7168,8 @@ snapshots: punycode@2.3.1: {} + q@1.5.1: {} + querystringify@2.2.0: {} queue-microtask@1.2.3: {} diff --git a/frontend/src/app/api/products/route.ts b/frontend/src/app/api/products/route.ts new file mode 100644 index 00000000..a4610b0f --- /dev/null +++ b/frontend/src/app/api/products/route.ts @@ -0,0 +1,165 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { ProductService } from '@/../backend/back/product/service'; +import { uploadMultipleImagesFromFiles } from '@/utils/cloudinary'; + +const service = new ProductService(); + +export async function POST(req: NextRequest) { + try { + const url = new URL(req.url); + const pathname = url.pathname.replace('/api/products', '') || '/'; + if (pathname === '/' || pathname === '') { + const body = await req.json(); + const userId = req.headers.get('x-user-id') || ''; + const product = await service.createProduct(body, userId); + return NextResponse.json({ success: true, product }); + } + if (pathname === '/bulk') { + const body = await req.json(); + const userId = req.headers.get('x-user-id') || ''; + const results: any[] = []; + for (let i = 0; i < body.length; i++) { + try { + const p = await service.createProduct(body[i], userId); + results.push(p); + } catch (e: any) { + const errStr = String(e?.message ?? e); + if ( + errStr.includes('duplicate') || + errStr.includes('duplicate key') + ) { + try { + const modified = { + ...body[i], + sku: `${body[i].sku || 'sku'}_${i + 1}`, + }; + const p2 = await service.createProduct(modified, userId); + results.push(p2); + } catch (e2) { + continue; + } + } else { + continue; + } + } + } + return NextResponse.json(results); + } + return NextResponse.json({ error: 'Not found' }, { status: 404 }); + } catch (err: any) { + return NextResponse.json( + { success: false, message: String(err?.message ?? err) }, + { status: 500 } + ); + } +} + +export async function GET(req: NextRequest) { + try { + const url = new URL(req.url); + const pathname = url.pathname.replace('/api/products', '') || '/'; + if (pathname === '/' || pathname === '') { + const userId = req.headers.get('x-user-id') || ''; + const products = await service.listProducts(userId); + return NextResponse.json({ success: true, products }); + } + const parts = pathname.split('/').filter(Boolean); + if (parts[0] === 'active') { + const userId = req.headers.get('x-user-id') || ''; + const products = await service.listProducts(userId); + const active = products.filter( + (p) => String(p.status || '').toLowerCase() === 'active' + ); + return NextResponse.json({ success: true, products: active }); + } + if (parts[0]) { + const product = await service.getProduct(parts[0]); + return NextResponse.json({ + success: true, + message: 'Product found', + product, + }); + } + return NextResponse.json({ error: 'Not found' }, { status: 404 }); + } catch (err: any) { + return NextResponse.json( + { success: false, message: String(err?.message ?? err) }, + { status: 500 } + ); + } +} + +export async function PUT(req: NextRequest) { + try { + const url = new URL(req.url); + const pathname = url.pathname.replace('/api/products', '') || '/'; + const parts = pathname.split('/').filter(Boolean); + if (parts[0]) { + const body = await req.json(); + const userTimezone = req.headers.get('x-user-timezone') || 'UTC'; + const updated = await service.updateProduct(parts[0], body, userTimezone); + return NextResponse.json({ + success: true, + message: 'Product updated successfully', + product: updated, + }); + } + return NextResponse.json({ error: 'Not found' }, { status: 404 }); + } catch (err: any) { + return NextResponse.json( + { success: false, message: String(err?.message ?? err) }, + { status: 500 } + ); + } +} + +export async function DELETE(req: NextRequest) { + try { + const url = new URL(req.url); + const pathname = url.pathname.replace('/api/products', '') || '/'; + const parts = pathname.split('/').filter(Boolean); + if (parts[0]) { + const deleted = await service.deleteProduct(parts[0]); + return NextResponse.json({ + success: true, + message: 'Product deleted successfully', + product: deleted, + }); + } + return NextResponse.json({ error: 'Not found' }, { status: 404 }); + } catch (err: any) { + return NextResponse.json( + { success: false, message: String(err?.message ?? err) }, + { status: 500 } + ); + } +} + +export async function POST_images(req: NextRequest) { + try { + const contentType = req.headers.get('content-type') || ''; + if (!contentType.includes('multipart/form-data')) { + return NextResponse.json( + { success: false, message: 'Invalid content type' }, + { status: 400 } + ); + } + const form = await req.formData(); + const files: File[] = []; + for (const [key, value] of form.entries()) { + if (value instanceof File) files.push(value as File); + } + if (files.length === 0) + return NextResponse.json( + { success: false, message: 'No files provided' }, + { status: 400 } + ); + const urls = await uploadMultipleImagesFromFiles(files); + return NextResponse.json({ success: true, urls }); + } catch (err: any) { + return NextResponse.json( + { success: false, message: String(err?.message ?? err) }, + { status: 500 } + ); + } +} diff --git a/frontend/src/utils/cloudinary.ts b/frontend/src/utils/cloudinary.ts new file mode 100644 index 00000000..76e3d0ac --- /dev/null +++ b/frontend/src/utils/cloudinary.ts @@ -0,0 +1,104 @@ +import cloudinary from 'cloudinary'; + +const { CLOUDINARY_CLOUD_NAME, CLOUDINARY_API_KEY, CLOUDINARY_API_SECRET } = + process.env; + +cloudinary.v2.config({ + cloud_name: CLOUDINARY_CLOUD_NAME, + api_key: CLOUDINARY_API_KEY, + api_secret: CLOUDINARY_API_SECRET, +}); + +const ALLOWED_EXTENSIONS = new Set(['jpg', 'jpeg', 'png', 'webp']); +const MAX_FILE_SIZE = 5 * 1024 * 1024; + +export async function uploadImageFromBase64( + base64String: string +): Promise { + if (!base64String.startsWith('data:image/')) + throw new Error('Invalid base64 format'); + const parts = base64String.split(','); + if (parts.length < 2) throw new Error('Invalid base64 format'); + const data = Buffer.from(parts[1], 'base64'); + if (data.length > MAX_FILE_SIZE) + throw new Error('File size exceeds 5MB limit'); + const res = await new Promise((resolve, reject) => { + const stream = cloudinary.v2.uploader.upload_stream( + { folder: 'products' }, + (error: unknown, result: any) => { + if (error) return reject(error); + resolve(result); + } + ); + stream.end(data); + }); + return res.secure_url; +} + +export async function uploadMultipleImagesFromBase64( + base64Strings: string[] +): Promise { + const urls: string[] = []; + for (const s of base64Strings) { + if (!s) continue; + const url = await uploadImageFromBase64(s); + urls.push(url); + } + return urls; +} + +export async function uploadImageToCloudinary(file: File): Promise { + const size = (file as any).size || 0; + if (size > MAX_FILE_SIZE) throw new Error('File size exceeds 5MB limit'); + const name = (file as any).name || ''; + const lower = name.toLowerCase(); + if (![...ALLOWED_EXTENSIONS].some((ext) => lower.endsWith(ext))) + throw new Error('Invalid file type. Only jpg, jpeg, png, webp allowed'); + const arrayBuffer = await file.arrayBuffer(); + const buffer = Buffer.from(arrayBuffer); + const res = await new Promise((resolve, reject) => { + const stream = cloudinary.v2.uploader.upload_stream( + { folder: 'products' }, + (error: unknown, result: any) => { + if (error) return reject(error); + resolve(result); + } + ); + stream.end(buffer); + }); + return res.secure_url; +} + +export async function uploadProfileImage(file: File): Promise { + const size = (file as any).size || 0; + if (size > MAX_FILE_SIZE) throw new Error('File size exceeds 5MB limit'); + const name = (file as any).name || ''; + const lower = name.toLowerCase(); + if (![...ALLOWED_EXTENSIONS].some((ext) => lower.endsWith(ext))) + throw new Error('Invalid file type. Only jpg, jpeg, png, webp allowed'); + const arrayBuffer = await file.arrayBuffer(); + const buffer = Buffer.from(arrayBuffer); + const res = await new Promise((resolve, reject) => { + const stream = cloudinary.v2.uploader.upload_stream( + { folder: 'profiles' }, + (error: unknown, result: any) => { + if (error) return reject(error); + resolve(result); + } + ); + stream.end(buffer); + }); + return res.secure_url; +} + +export async function uploadMultipleImagesFromFiles( + files: File[] +): Promise { + const urls: string[] = []; + for (const f of files) { + if (!f) continue; + const url = await uploadImageToCloudinary(f); + urls.push(url); + } + return urls; +} From eebf7015b85a825a7ab8da1ef5057cc0c8d572a3 Mon Sep 17 00:00:00 2001 From: Joaco Curbelo Date: Tue, 16 Sep 2025 22:14:41 +0200 Subject: [PATCH 07/35] Direccion de botones de Pircing a /login --- frontend/src/components/landing/pricing-section.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/landing/pricing-section.tsx b/frontend/src/components/landing/pricing-section.tsx index cec00ee8..4b737674 100644 --- a/frontend/src/components/landing/pricing-section.tsx +++ b/frontend/src/components/landing/pricing-section.tsx @@ -59,12 +59,12 @@ export function PricingSection() { const handleSubscribe = async () => { try { if (process.env.NEXT_PUBLIC_ENABLE_POLAR !== 'true') { - window.location.href = '/auth/login'; + window.location.href = '/login'; return; } const userId = user?.id; if (!userId) { - window.location.href = '/auth/login'; + window.location.href = '/login'; return; } const cycle = isAnnual ? 'annual' : 'monthly'; From 80d20940a4410438b59c8ae5fc78d98a17b88699 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?essp=C3=ADndola?= Date: Tue, 16 Sep 2025 17:35:41 -0300 Subject: [PATCH 08/35] Implement user authentication and management features with JWT, bcrypt, and Supabase integration --- frontend/backend/auth/models.ts | 34 ++++++ frontend/backend/auth/repository.ts | 76 ++++++++++++++ frontend/backend/auth/service.ts | 154 ++++++++++++++++++++++++++++ frontend/package.json | 3 + frontend/pnpm-lock.yaml | 109 ++++++++++++++++++++ frontend/src/app/api/auth/route.ts | 117 +++++++++++++++++++++ frontend/src/utils/password.ts | 10 ++ frontend/src/utils/timezone.ts | 3 + 8 files changed, 506 insertions(+) create mode 100644 frontend/backend/auth/models.ts create mode 100644 frontend/backend/auth/repository.ts create mode 100644 frontend/backend/auth/service.ts create mode 100644 frontend/src/app/api/auth/route.ts create mode 100644 frontend/src/utils/password.ts create mode 100644 frontend/src/utils/timezone.ts diff --git a/frontend/backend/auth/models.ts b/frontend/backend/auth/models.ts new file mode 100644 index 00000000..cc4546cf --- /dev/null +++ b/frontend/backend/auth/models.ts @@ -0,0 +1,34 @@ +export type RoleUser = 'admin' | 'manager' | 'user'; + +export interface UserItem { + id: string; + full_name: string; + email: string; + password?: string | null; + role?: RoleUser; + phone?: string | null; + profile_image?: string | null; +} + +export interface LoginRequest { + email: string; + password: string; +} + +export interface RegisterRequest { + full_name: string; + email: string; + password: string; + phone?: string | null; +} + +export interface UserInfo { + id: string; + email: string; + full_name: string; + phone?: string | null; + address?: string | null; + role: RoleUser; + created_at: string; + last_updated: string; +} diff --git a/frontend/backend/auth/repository.ts b/frontend/backend/auth/repository.ts new file mode 100644 index 00000000..5df3d6d7 --- /dev/null +++ b/frontend/backend/auth/repository.ts @@ -0,0 +1,76 @@ +import { createClient } from '@/utils/supabase/server'; +import type { UserItem } from './models'; +import { getCurrentTimeWithTimezone } from '@/utils/timezone'; + +export class UserRepository { + table = 'users'; + + async findAllUsers(): Promise { + const supabase = await createClient(); + const { data } = await supabase.from(this.table).select('*'); + return (data || []) as UserItem[]; + } + + async findByEmail(email: string): Promise { + if (!email) return null; + const supabase = await createClient(); + const { data } = await supabase + .from(this.table) + .select('*') + .eq('email', email) + .limit(1); + if (!data || data.length === 0) return null; + return data[0] as UserItem; + } + + async findByUserId(user_id: string): Promise { + if (!user_id) return null; + const supabase = await createClient(); + const { data } = await supabase + .from(this.table) + .select('*') + .eq('id', user_id) + .limit(1); + if (!data || data.length === 0) return null; + return data[0] as UserItem; + } + + async createUser(payload: Record): Promise { + const supabase = await createClient(); + const now = getCurrentTimeWithTimezone('UTC'); + const insertPayload = { + full_name: payload.full_name, + email: payload.email, + password: payload.password, + phone: payload.phone || null, + role: payload.role || 'user', + profile_image: payload.profile_image || null, + created_at: now, + last_updated: now, + }; + const { data, error } = await supabase + .from(this.table) + .insert(insertPayload) + .select() + .single(); + if (error) throw error; + return (data && data.id) || ''; + } + + async updateProfile( + user_id: string, + updates: Record + ): Promise { + const supabase = await createClient(); + const now = getCurrentTimeWithTimezone('UTC'); + updates.last_updated = now; + const { data, error } = await supabase + .from(this.table) + .update(updates) + .eq('id', user_id) + .select() + .single(); + if (error) throw error; + return data as UserItem; + } +} diff --git a/frontend/backend/auth/service.ts b/frontend/backend/auth/service.ts new file mode 100644 index 00000000..f1246e1d --- /dev/null +++ b/frontend/backend/auth/service.ts @@ -0,0 +1,154 @@ +import jwt, { SignOptions, Secret } from 'jsonwebtoken'; +import { UserRepository } from './repository'; +import type { UserItem } from './models'; +import { hashPassword, verifyPassword } from '@/utils/password'; +import { uploadProfileImage } from '@/utils/cloudinary'; + +const SECRET_KEY = process.env.SECRET_KEY; +const ALGORITHM = 'HS256'; +const ACCESS_TOKEN_EXPIRE_MINUTES = Number( + process.env.ACCESS_TOKEN_EXPIRE_MINUTES || 60 +); + +export class AuthService { + repo: UserRepository; + blacklisted: Set; + constructor(repo?: UserRepository) { + this.repo = repo || new UserRepository(); + this.blacklisted = new Set(); + } + + createAccessToken( + data: Record, + expiresMinutes?: number + ): string { + const payload = { ...data }; + const expiresIn = `${expiresMinutes || ACCESS_TOKEN_EXPIRE_MINUTES}m`; + const options: SignOptions = { + algorithm: ALGORITHM as unknown as SignOptions['algorithm'], + expiresIn: expiresIn as any, + }; + return jwt.sign(payload, SECRET_KEY as unknown as Secret, options); + } + + async login( + email: string, + password: string + ): Promise<{ user: UserItem; token: string }> { + if (!email || !password) throw new Error('Email and password are required'); + const user = await this.repo.findByEmail(email); + if (!user) throw new Error('Invalid credentials'); + try { + const hashed = (user as any).password as string | undefined; + if (!hashed || !verifyPassword(password, hashed)) + throw new Error('Invalid credentials'); + } catch (e) { + throw new Error('Authentication error'); + } + const userData: UserItem = { ...user }; + delete (userData as any).password; + const token = this.createAccessToken({ user_id: user.id }); + return { user: userData, token }; + } + + async register( + full_name: string, + email: string, + password: string, + phone?: string + ): Promise<{ user: UserItem; token: string }> { + if (!email || !password || !full_name) + throw new Error('Email, password and fullname are required'); + const existing = await this.repo.findByEmail(email); + if (existing) throw new Error('Email already exists'); + const hashed = hashPassword(password); + const user_id = await this.repo.createUser({ + full_name, + email, + password: hashed, + phone, + }); + return this.login(email, password); + } + + logout(token: string) { + this.blacklisted.add(token); + return { success: true, message: 'Successful logout' }; + } + + async verifyToken(token: string): Promise { + try { + const payload: any = jwt.verify(token, SECRET_KEY as unknown as Secret); + const user_id = payload.user_id; + if (!user_id) throw new Error('Invalid Token'); + const user = await this.repo.findByUserId(String(user_id)); + if (!user) throw new Error('User not found'); + return user; + } catch (e) { + throw new Error('Invalid Token'); + } + } + + isTokenBlacklisted(token: string) { + return this.blacklisted.has(token); + } + + async getListUsers(): Promise<{ success: true; users: UserItem[] }> { + const users = await this.repo.findAllUsers(); + return { success: true, users }; + } + + async getProfileUser( + user_id: string + ): Promise<{ success: true; user: UserItem | null }> { + const user = await this.repo.findByUserId(user_id); + return { success: true, user }; + } + + async updateProfile( + user_id: string, + updates: Record + ): Promise<{ success: true; user: UserItem }> { + const user = await this.repo.updateProfile(user_id, updates); + return { success: true, user }; + } + + async uploadProfileImage( + user_id: string, + file: File + ): Promise<{ success: true; user: UserItem }> { + try { + const url = await uploadProfileImage(file as any); + const user = await this.repo.updateProfile(user_id, { + profile_image: url, + }); + return { success: true, user }; + } catch (e: any) { + throw new Error(String(e?.message ?? e)); + } + } + + async deleteProfileImage( + user_id: string + ): Promise<{ success: true; user: UserItem }> { + const user = await this.repo.updateProfile(user_id, { + profile_image: null, + }); + return { success: true, user }; + } + + async updateProfileImage( + user_id: string, + file: File + ): Promise<{ success: true; user: UserItem }> { + try { + const url = await uploadProfileImage(file as any); + const user = await this.repo.updateProfile(user_id, { + profile_image: url, + }); + return { success: true, user }; + } catch (e: any) { + throw new Error(String(e?.message ?? e)); + } + } +} diff --git a/frontend/package.json b/frontend/package.json index 1b950a29..bf959b8f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -42,6 +42,7 @@ "@supabase/ssr": "^0.7.0", "@supabase/supabase-js": "^2.56.0", "axios": "^1.7.7", + "bcryptjs": "^3.0.2", "class-variance-authority": "^0.7.1", "cloudinary": "^2.7.0", "clsx": "^2.1.1", @@ -51,6 +52,7 @@ "geist": "^1.3.1", "input-otp": "^1.4.2", "js-cookie": "^3.0.5", + "jsonwebtoken": "^9.0.2", "lucide-react": "^0.344.0", "next": "15.5.0", "next-themes": "^0.4.6", @@ -75,6 +77,7 @@ "@eslint/eslintrc": "^3", "@tailwindcss/postcss": "^4", "@types/js-cookie": "^3.0.6", + "@types/jsonwebtoken": "^9.0.10", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 96fcf4ba..58f286a9 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -107,6 +107,9 @@ importers: axios: specifier: ^1.7.7 version: 1.11.0 + bcryptjs: + specifier: ^3.0.2 + version: 3.0.2 class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -134,6 +137,9 @@ importers: js-cookie: specifier: ^3.0.5 version: 3.0.5 + jsonwebtoken: + specifier: ^9.0.2 + version: 9.0.2 lucide-react: specifier: ^0.344.0 version: 0.344.0(react@19.1.0) @@ -201,6 +207,9 @@ importers: '@types/js-cookie': specifier: ^3.0.6 version: 3.0.6 + '@types/jsonwebtoken': + specifier: ^9.0.10 + version: 9.0.10 '@types/node': specifier: ^20 version: 20.19.11 @@ -1726,6 +1735,12 @@ packages: '@types/json5@0.0.29': resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} + '@types/jsonwebtoken@9.0.10': + resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==} + + '@types/ms@2.1.0': + resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + '@types/node@20.19.11': resolution: {integrity: sha512-uug3FEEGv0r+jrecvUUpbY8lLisvIjg6AAic6a2bSP5OEOLeJsDSnvhCDov7ipFFMXS3orMpzlmi0ZcuGkBbow==} @@ -2018,6 +2033,10 @@ packages: base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + bcryptjs@3.0.2: + resolution: {integrity: sha512-k38b3XOZKv60C4E2hVsXTolJWfkGRMbILBIe2IBITXciy5bOsTKot5kDrf3ZfufQtQOUN5mXceUEpU1rTl9Uog==} + hasBin: true + brace-expansion@1.1.12: resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} @@ -2028,6 +2047,9 @@ packages: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} + buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + buffer@6.0.3: resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} @@ -2268,6 +2290,9 @@ packages: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} + ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + embla-carousel-react@8.6.0: resolution: {integrity: sha512-0/PjqU7geVmo6F734pmPqpyHqiM99olvyecY7zdweCw+6tKEXnrE90pBiBbMMU8s5tICemzpQ3hi5EpxzGW+JA==} peerDependencies: @@ -2836,10 +2861,20 @@ packages: resolution: {integrity: sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==} hasBin: true + jsonwebtoken@9.0.2: + resolution: {integrity: sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==} + engines: {node: '>=12', npm: '>=6'} + jsx-ast-utils@3.3.5: resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} engines: {node: '>=4.0'} + jwa@1.4.2: + resolution: {integrity: sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==} + + jws@3.2.2: + resolution: {integrity: sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==} + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -2928,9 +2963,30 @@ packages: lodash.debounce@4.0.8: resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} + lodash.includes@4.3.0: + resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} + + lodash.isboolean@3.0.3: + resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} + + lodash.isinteger@4.0.4: + resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==} + + lodash.isnumber@3.0.3: + resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==} + + lodash.isplainobject@4.0.6: + resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} + + lodash.isstring@4.0.1: + resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==} + lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + lodash.once@4.1.1: + resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} + lodash@4.17.21: resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} @@ -5566,6 +5622,13 @@ snapshots: '@types/json5@0.0.29': {} + '@types/jsonwebtoken@9.0.10': + dependencies: + '@types/ms': 2.1.0 + '@types/node': 20.19.11 + + '@types/ms@2.1.0': {} + '@types/node@20.19.11': dependencies: undici-types: 6.21.0 @@ -5890,6 +5953,8 @@ snapshots: base64-js@1.5.1: {} + bcryptjs@3.0.2: {} + brace-expansion@1.1.12: dependencies: balanced-match: 1.0.2 @@ -5903,6 +5968,8 @@ snapshots: dependencies: fill-range: 7.1.1 + buffer-equal-constant-time@1.0.1: {} + buffer@6.0.3: dependencies: base64-js: 1.5.1 @@ -6127,6 +6194,10 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 + ecdsa-sig-formatter@1.0.11: + dependencies: + safe-buffer: 5.2.1 + embla-carousel-react@8.6.0(react@19.1.0): dependencies: embla-carousel: 8.6.0 @@ -6842,6 +6913,19 @@ snapshots: dependencies: minimist: 1.2.8 + jsonwebtoken@9.0.2: + 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.1.1 + ms: 2.1.3 + semver: 7.7.2 + jsx-ast-utils@3.3.5: dependencies: array-includes: 3.1.9 @@ -6849,6 +6933,17 @@ snapshots: object.assign: 4.1.7 object.values: 1.2.1 + jwa@1.4.2: + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + + jws@3.2.2: + dependencies: + jwa: 1.4.2 + safe-buffer: 5.2.1 + keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -6917,8 +7012,22 @@ snapshots: lodash.debounce@4.0.8: {} + 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.merge@4.6.2: {} + lodash.once@4.1.1: {} + lodash@4.17.21: {} loose-envify@1.4.0: diff --git a/frontend/src/app/api/auth/route.ts b/frontend/src/app/api/auth/route.ts new file mode 100644 index 00000000..eaf37aac --- /dev/null +++ b/frontend/src/app/api/auth/route.ts @@ -0,0 +1,117 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { AuthService } from '@/../backend/auth/service'; +import { UserRepository } from '@/../backend/auth/repository'; + +const service = new AuthService(new UserRepository()); + +export async function POST(req: NextRequest) { + try { + const url = new URL(req.url); + const pathname = url.pathname.replace('/api/auth', '') || '/'; + if (pathname === '/login') { + const body = await req.json(); + const res = await service.login(body.email, body.password); + return NextResponse.json(res); + } + if (pathname === '/register') { + const body = await req.json(); + const res = await service.register( + body.full_name, + body.email, + body.password, + body.phone + ); + return NextResponse.json(res); + } + return NextResponse.json({ error: 'Not found' }, { status: 404 }); + } catch (err: any) { + return NextResponse.json( + { success: false, message: String(err?.message ?? err) }, + { status: 500 } + ); + } +} + +export async function GET(req: NextRequest) { + try { + const url = new URL(req.url); + const pathname = url.pathname.replace('/api/auth', '') || '/'; + if (pathname === '/me') { + const auth = req.headers.get('authorization') || ''; + const token = + auth.replace('Bearer ', '') || req.headers.get('x-token') || ''; + const user = await service.verifyToken(token); + return NextResponse.json(user); + } + if (pathname === '/users') { + const res = await service.getListUsers(); + return NextResponse.json(res); + } + return NextResponse.json({ error: 'Not found' }, { status: 404 }); + } catch (err: any) { + return NextResponse.json( + { success: false, message: String(err?.message ?? err) }, + { status: 500 } + ); + } +} + +export async function PUT(req: NextRequest) { + try { + const url = new URL(req.url); + const pathname = url.pathname.replace('/api/auth', '') || '/'; + if (pathname === '/update-profile-image') { + const form = await req.formData(); + const file = Array.from(form.values()).find((v) => v instanceof File) as + | File + | undefined; + const auth = req.headers.get('authorization') || ''; + const token = + auth.replace('Bearer ', '') || req.headers.get('x-token') || ''; + const user = await service.verifyToken(token); + if (!file) + return NextResponse.json( + { success: false, message: 'No file' }, + { status: 400 } + ); + const res = await service.updateProfileImage(user.id, file); + return NextResponse.json(res); + } + if (pathname === '/update-profile') { + const auth = req.headers.get('authorization') || ''; + const token = + auth.replace('Bearer ', '') || req.headers.get('x-token') || ''; + const user = await service.verifyToken(token); + const body = await req.json(); + const res = await service.updateProfile(user.id, body); + return NextResponse.json(res); + } + return NextResponse.json({ error: 'Not found' }, { status: 404 }); + } catch (err: any) { + return NextResponse.json( + { success: false, message: String(err?.message ?? err) }, + { status: 500 } + ); + } +} + +export async function DELETE(req: NextRequest) { + try { + const url = new URL(req.url); + const pathname = url.pathname.replace('/api/auth', '') || '/'; + if (pathname === '/delete-profile-image') { + const auth = req.headers.get('authorization') || ''; + const token = + auth.replace('Bearer ', '') || req.headers.get('x-token') || ''; + const user = await service.verifyToken(token); + const res = await service.deleteProfileImage(user.id); + return NextResponse.json(res); + } + return NextResponse.json({ error: 'Not found' }, { status: 404 }); + } catch (err: any) { + return NextResponse.json( + { success: false, message: String(err?.message ?? err) }, + { status: 500 } + ); + } +} diff --git a/frontend/src/utils/password.ts b/frontend/src/utils/password.ts new file mode 100644 index 00000000..cc03e4a8 --- /dev/null +++ b/frontend/src/utils/password.ts @@ -0,0 +1,10 @@ +import bcrypt from 'bcryptjs'; + +export function hashPassword(password: string): string { + const salt = bcrypt.genSaltSync(10); + return bcrypt.hashSync(password, salt); +} + +export function verifyPassword(password: string, hashed: string): boolean { + return bcrypt.compareSync(password, hashed); +} diff --git a/frontend/src/utils/timezone.ts b/frontend/src/utils/timezone.ts new file mode 100644 index 00000000..ab15add4 --- /dev/null +++ b/frontend/src/utils/timezone.ts @@ -0,0 +1,3 @@ +export function getCurrentTimeWithTimezone(_tz: string = 'UTC'): string { + return new Date().toISOString(); +} From 0ea2939ec05e483def8a0c68f0c48afdc62184e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?essp=C3=ADndola?= Date: Tue, 16 Sep 2025 18:38:07 -0300 Subject: [PATCH 09/35] Add UpgradePage and PricingSectionCompact components; update routing and layout --- frontend/src/app/upgrade/page.tsx | 14 + frontend/src/components/LayoutWrapper.tsx | 2 + .../components/auth/register/RegisterForm.tsx | 2 +- .../landing/pricing-section-compact.tsx | 334 ++++++++++++++++ .../components/landing/pricing-section.tsx | 361 ++++++++++-------- 5 files changed, 550 insertions(+), 163 deletions(-) create mode 100644 frontend/src/app/upgrade/page.tsx create mode 100644 frontend/src/components/landing/pricing-section-compact.tsx diff --git a/frontend/src/app/upgrade/page.tsx b/frontend/src/app/upgrade/page.tsx new file mode 100644 index 00000000..fd03df5f --- /dev/null +++ b/frontend/src/app/upgrade/page.tsx @@ -0,0 +1,14 @@ +import React from 'react'; +import { PricingSectionCompact } from '@/components/landing/pricing-section-compact'; + +export default function UpgradePage() { + return ( +
+
+
+ +
+
+
+ ); +} diff --git a/frontend/src/components/LayoutWrapper.tsx b/frontend/src/components/LayoutWrapper.tsx index 1dcc1109..b2549be5 100644 --- a/frontend/src/components/LayoutWrapper.tsx +++ b/frontend/src/components/LayoutWrapper.tsx @@ -12,6 +12,8 @@ const LayoutWrapper: React.FC<{ children: React.ReactNode }> = ({ '/', '/login', '/register', + '/upgrade', + '/landing', '/auth/login', '/auth/register', ]; diff --git a/frontend/src/components/auth/register/RegisterForm.tsx b/frontend/src/components/auth/register/RegisterForm.tsx index 0cde7db2..33dd2e6b 100644 --- a/frontend/src/components/auth/register/RegisterForm.tsx +++ b/frontend/src/components/auth/register/RegisterForm.tsx @@ -44,7 +44,7 @@ const RegisterForm: React.FC = () => { }; await registerUser(payload as any, true); - router.push('/home'); + router.push('/upgrade'); } catch (e: any) { console.error('[RegisterForm] error', e); const msg = diff --git a/frontend/src/components/landing/pricing-section-compact.tsx b/frontend/src/components/landing/pricing-section-compact.tsx new file mode 100644 index 00000000..a87a6f5b --- /dev/null +++ b/frontend/src/components/landing/pricing-section-compact.tsx @@ -0,0 +1,334 @@ +'use client'; + +import { useState } from 'react'; +import { Check } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { useLanguageContext } from '@/context/language-context'; +import { getTranslation } from '@/utils/translations'; +import { useAuth } from '@/context/auth-store'; +import { createCheckout } from '@/services/payments-service'; + +export function PricingSectionCompact() { + const { language } = useLanguageContext(); + const [isAnnual, setIsAnnual] = useState(true); + const { user } = useAuth(); + + const pricingPlans = [ + { + name: getTranslation(language, 'pricing.plans.tester.name'), + monthlyPrice: '$0', + annualPrice: '$0', + description: getTranslation(language, 'pricing.plans.tester.description'), + features: getTranslation(language, 'pricing.plans.tester.features'), + buttonText: getTranslation(language, 'pricing.plans.tester.buttonText'), + buttonClass: + 'bg-zinc-300 shadow-[0px_1px_1px_-0.5px_rgba(16,24,40,0.20)] outline outline-0.5 outline-[#1e29391f] outline-offset-[-0.5px] text-gray-800 text-shadow-[0px_1px_1px_rgba(16,24,40,0.08)] hover:bg-zinc-400', + }, + { + name: getTranslation(language, 'pricing.plans.starter.name'), + monthlyPrice: '$30', + annualPrice: '$200', + description: getTranslation( + language, + 'pricing.plans.starter.description' + ), + features: getTranslation(language, 'pricing.plans.starter.features'), + buttonText: getTranslation(language, 'pricing.plans.starter.buttonText'), + buttonClass: + 'bg-white shadow-[0px_1px_1px_-0.5px_rgba(16,24,40,0.20)] text-orange-500 text-shadow-[0px_1px_1px_rgba(16,24,40,0.08)] hover:bg-white/90', + popular: true, + }, + { + name: getTranslation(language, 'pricing.plans.enterprise.name'), + monthlyPrice: '', + annualPrice: '', + description: getTranslation( + language, + 'pricing.plans.enterprise.description' + ), + features: getTranslation(language, 'pricing.plans.enterprise.features'), + buttonText: getTranslation( + language, + 'pricing.plans.enterprise.buttonText' + ), + buttonClass: + 'bg-[#F2F2F2] shadow-[0px_1px_1px_-0.5px_rgba(16,24,40,0.20)] text-black text-shadow-[0px_1px_1px_rgba(16,24,40,0.08)] hover:bg-[#F2F2F2]/90', + }, + ]; + + const handleSubscribe = async () => { + try { + const userId = user?.id; + const cycle = isAnnual ? 'annual' : 'monthly'; + const plan = 'starter'; + const res = await createCheckout(userId || '', plan, cycle); + if (res?.url) { + window.location.href = res.url; + } + } catch {} + }; + + return ( +
+

Upgrade Plan

+
+
+
+
+ + +
+
+
+
+
+ {pricingPlans.map((plan) => { + const parseNumber = (s: string) => { + const n = Number(String(s).replace(/[^0-9.]/g, '')); + return Number.isFinite(n) ? n : 0; + }; + const monthlyFromAnnual = plan.annualPrice + ? `$${Math.floor(parseNumber(plan.annualPrice) / 12)}` + : plan.monthlyPrice; + const displayMainPrice = + isAnnual && plan.annualPrice ? plan.annualPrice : plan.monthlyPrice; + const displaySecondaryPrice = + isAnnual && plan.annualPrice + ? `${Math.floor(parseNumber(plan.annualPrice) / 12)}/mes` + : ''; + return ( +
+
+
+
+ {plan.name} + {plan.popular && ( +
+
+ {getTranslation(language, 'pricing.popular')} +
+
+ )} +
+
+ {(plan.monthlyPrice || plan.annualPrice) && ( +
+
+
+ + {displayMainPrice} + + + {displayMainPrice} + + + {plan.monthlyPrice} + +
+ {displaySecondaryPrice ? ( +
+ {displaySecondaryPrice} +
+ ) : null} +
+ {!isAnnual && ( +
+ {plan.name === + getTranslation( + language, + 'pricing.plans.tester.name' + ) + ? getTranslation( + language, + 'pricing.period.tester' + ) + : getTranslation( + language, + 'pricing.period.other' + )} +
+ )} +
+ )} +
+ {plan.description} +
+
+
+ +
+
+ {plan.name === + getTranslation(language, 'pricing.plans.tester.name') + ? getTranslation(language, 'pricing.includes') + : plan.name} +
+
+ {plan.features.map((feature: string) => ( +
+
+ +
+
+ {feature} +
+
+ ))} +
+
+ + {plan.name === + getTranslation(language, 'pricing.plans.starter.name') ? ( + + ) : ( + + )} +
+
+ ); + })} +
+
+ ); +} diff --git a/frontend/src/components/landing/pricing-section.tsx b/frontend/src/components/landing/pricing-section.tsx index 4b737674..72cd9b2d 100644 --- a/frontend/src/components/landing/pricing-section.tsx +++ b/frontend/src/components/landing/pricing-section.tsx @@ -125,185 +125,222 @@ export function PricingSection() {
- {pricingPlans.map((plan) => ( -
-
-
-
- {plan.name} - {plan.popular && ( -
-
- {getTranslation(language, 'pricing.popular')} -
-
- )} -
-
- {(plan.monthlyPrice || plan.annualPrice) && ( -
-
- - {isAnnual ? plan.annualPrice : plan.monthlyPrice} - - - {plan.annualPrice} - - - {plan.monthlyPrice} - + {pricingPlans.map((plan) => { + const parseNumber = (s: string) => { + const n = Number(String(s).replace(/[^0-9.]/g, '')); + return Number.isFinite(n) ? n : 0; + }; + const monthlyFromAnnual = plan.annualPrice + ? `$${Math.floor(parseNumber(plan.annualPrice) / 12)}` + : plan.monthlyPrice; + const displayMainPrice = + isAnnual && plan.annualPrice ? plan.annualPrice : plan.monthlyPrice; + const displaySecondaryPrice = + isAnnual && plan.annualPrice + ? `${Math.floor(parseNumber(plan.annualPrice) / 12)}/mes` + : ''; + return ( +
+
+
+
+ {plan.name} + {plan.popular && ( +
+
+ {getTranslation(language, 'pricing.popular')} +
-
- {plan.name === - getTranslation(language, 'pricing.plans.tester.name') - ? getTranslation(language, 'pricing.period.tester') - : getTranslation(language, 'pricing.period.other')} + )} +
+
+ {(plan.monthlyPrice || plan.annualPrice) && ( +
+
+
+ + {displayMainPrice} + + + {displayMainPrice} + + + {plan.monthlyPrice} + +
+ {displaySecondaryPrice ? ( +
+ {displaySecondaryPrice} +
+ ) : null} +
+ {!isAnnual && ( +
+ {plan.name === + getTranslation( + language, + 'pricing.plans.tester.name' + ) + ? getTranslation( + language, + 'pricing.period.tester' + ) + : getTranslation( + language, + 'pricing.period.other' + )} +
+ )}
+ )} +
+ {plan.description}
- )} +
+
+ +
- {plan.description} + {plan.name === + getTranslation(language, 'pricing.plans.tester.name') + ? getTranslation(language, 'pricing.includes') + : plan.name}
-
-
- -
-
- {plan.name === - getTranslation(language, 'pricing.plans.tester.name') - ? getTranslation(language, 'pricing.includes') - : plan.name} -
-
- {plan.features.map((feature: string) => ( -
-
- + {plan.features.map((feature: string) => ( +
+
+ +
+
+ > + {feature} +
-
+
+ + {plan.name === + getTranslation(language, 'pricing.plans.starter.name') ? ( +
- ))} -
+ + ) : ( + + )}
- - {plan.name === - getTranslation(language, 'pricing.plans.starter.name') ? ( - - ) : ( - - )}
-
- ))} + ); + })}
); From 0ff6e70ae2deca41432863b24710bc20015337a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?essp=C3=ADndola?= Date: Wed, 17 Sep 2025 11:39:29 -0300 Subject: [PATCH 10/35] Add Upgrade button to SideMenu for user navigation --- frontend/src/components/SideMenu.tsx | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/SideMenu.tsx b/frontend/src/components/SideMenu.tsx index 739e9d5f..058e651f 100644 --- a/frontend/src/components/SideMenu.tsx +++ b/frontend/src/components/SideMenu.tsx @@ -8,6 +8,7 @@ import { Plus, Archive, Brain, + Sparkles, LogOut, User, Menu, @@ -311,7 +312,18 @@ const SideMenu: React.FC = () => { {renderMenuItems(menuItems)} - {/* User Info */} +
+
+ +
+
+
@@ -351,7 +363,18 @@ const SideMenu: React.FC = () => { {renderMenuItems(menuItems)} - {/* User Info with Hover Animation */} +
+
+ +
+
+
Date: Wed, 17 Sep 2025 13:39:50 -0300 Subject: [PATCH 11/35] Add polar API key handling and encryption to user model and repository; implement new API routes for login and registration --- frontend/backend/auth/models.ts | 1 + frontend/backend/auth/repository.ts | 25 +++++ frontend/backend/auth/service.ts | 10 ++ frontend/src/app/api/auth/login/route.ts | 18 +++ frontend/src/app/api/auth/register/route.ts | 23 ++++ frontend/src/app/api/auth/route.ts | 117 -------------------- frontend/src/services/api.service.ts | 91 +++++++-------- frontend/src/utils/crypto.ts | 35 ++++++ 8 files changed, 154 insertions(+), 166 deletions(-) create mode 100644 frontend/src/app/api/auth/login/route.ts create mode 100644 frontend/src/app/api/auth/register/route.ts delete mode 100644 frontend/src/app/api/auth/route.ts create mode 100644 frontend/src/utils/crypto.ts diff --git a/frontend/backend/auth/models.ts b/frontend/backend/auth/models.ts index cc4546cf..bbd2026c 100644 --- a/frontend/backend/auth/models.ts +++ b/frontend/backend/auth/models.ts @@ -8,6 +8,7 @@ export interface UserItem { role?: RoleUser; phone?: string | null; profile_image?: string | null; + polar_api_key?: string | null; } export interface LoginRequest { diff --git a/frontend/backend/auth/repository.ts b/frontend/backend/auth/repository.ts index 5df3d6d7..e85ea867 100644 --- a/frontend/backend/auth/repository.ts +++ b/frontend/backend/auth/repository.ts @@ -1,5 +1,6 @@ import { createClient } from '@/utils/supabase/server'; import type { UserItem } from './models'; +import { encryptString, decryptString } from '@/utils/crypto'; import { getCurrentTimeWithTimezone } from '@/utils/timezone'; export class UserRepository { @@ -45,6 +46,9 @@ export class UserRepository { phone: payload.phone || null, role: payload.role || 'user', profile_image: payload.profile_image || null, + polar_api_key: payload.polar_api_key + ? encryptString(String(payload.polar_api_key)) + : null, created_at: now, last_updated: now, }; @@ -64,6 +68,11 @@ export class UserRepository { const supabase = await createClient(); const now = getCurrentTimeWithTimezone('UTC'); updates.last_updated = now; + if (typeof updates.polar_api_key !== 'undefined') { + updates.polar_api_key = updates.polar_api_key + ? encryptString(String(updates.polar_api_key)) + : null; + } const { data, error } = await supabase .from(this.table) .update(updates) @@ -73,4 +82,20 @@ export class UserRepository { if (error) throw error; return data as UserItem; } + + async getDecryptedPolarKey(user_id: string): Promise { + const supabase = await createClient(); + const { data } = await supabase + .from(this.table) + .select('polar_api_key') + .eq('id', user_id) + .limit(1) + .single(); + if (!data || !data.polar_api_key) return null; + try { + return decryptString(data.polar_api_key as string); + } catch { + return null; + } + } } diff --git a/frontend/backend/auth/service.ts b/frontend/backend/auth/service.ts index f1246e1d..494d65d1 100644 --- a/frontend/backend/auth/service.ts +++ b/frontend/backend/auth/service.ts @@ -113,6 +113,16 @@ export class AuthService { return { success: true, user }; } + async setPolarApiKey(user_id: string, apiKey: string | null) { + await this.repo.updateProfile(user_id, { polar_api_key: apiKey }); + return { success: true }; + } + + async getPolarApiKey(user_id: string): Promise { + const key = await this.repo.getDecryptedPolarKey(user_id); + return key; + } + async uploadProfileImage( user_id: string, file: File diff --git a/frontend/src/app/api/auth/login/route.ts b/frontend/src/app/api/auth/login/route.ts new file mode 100644 index 00000000..947300d4 --- /dev/null +++ b/frontend/src/app/api/auth/login/route.ts @@ -0,0 +1,18 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { AuthService } from '@/../backend/auth/service'; +import { UserRepository } from '@/../backend/auth/repository'; + +const service = new AuthService(new UserRepository()); + +export async function POST(req: NextRequest) { + try { + const body = await req.json(); + const res = await service.login(body.email, body.password); + return NextResponse.json(res); + } catch (err: any) { + return NextResponse.json( + { success: false, message: String(err?.message ?? err) }, + { status: 500 } + ); + } +} diff --git a/frontend/src/app/api/auth/register/route.ts b/frontend/src/app/api/auth/register/route.ts new file mode 100644 index 00000000..7bf13d59 --- /dev/null +++ b/frontend/src/app/api/auth/register/route.ts @@ -0,0 +1,23 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { AuthService } from '@/../backend/auth/service'; +import { UserRepository } from '@/../backend/auth/repository'; + +const service = new AuthService(new UserRepository()); + +export async function POST(req: NextRequest) { + try { + const body = await req.json(); + const res = await service.register( + body.full_name, + body.email, + body.password, + body.phone + ); + return NextResponse.json(res); + } catch (err: any) { + return NextResponse.json( + { success: false, message: String(err?.message ?? err) }, + { status: 500 } + ); + } +} diff --git a/frontend/src/app/api/auth/route.ts b/frontend/src/app/api/auth/route.ts deleted file mode 100644 index eaf37aac..00000000 --- a/frontend/src/app/api/auth/route.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { AuthService } from '@/../backend/auth/service'; -import { UserRepository } from '@/../backend/auth/repository'; - -const service = new AuthService(new UserRepository()); - -export async function POST(req: NextRequest) { - try { - const url = new URL(req.url); - const pathname = url.pathname.replace('/api/auth', '') || '/'; - if (pathname === '/login') { - const body = await req.json(); - const res = await service.login(body.email, body.password); - return NextResponse.json(res); - } - if (pathname === '/register') { - const body = await req.json(); - const res = await service.register( - body.full_name, - body.email, - body.password, - body.phone - ); - return NextResponse.json(res); - } - return NextResponse.json({ error: 'Not found' }, { status: 404 }); - } catch (err: any) { - return NextResponse.json( - { success: false, message: String(err?.message ?? err) }, - { status: 500 } - ); - } -} - -export async function GET(req: NextRequest) { - try { - const url = new URL(req.url); - const pathname = url.pathname.replace('/api/auth', '') || '/'; - if (pathname === '/me') { - const auth = req.headers.get('authorization') || ''; - const token = - auth.replace('Bearer ', '') || req.headers.get('x-token') || ''; - const user = await service.verifyToken(token); - return NextResponse.json(user); - } - if (pathname === '/users') { - const res = await service.getListUsers(); - return NextResponse.json(res); - } - return NextResponse.json({ error: 'Not found' }, { status: 404 }); - } catch (err: any) { - return NextResponse.json( - { success: false, message: String(err?.message ?? err) }, - { status: 500 } - ); - } -} - -export async function PUT(req: NextRequest) { - try { - const url = new URL(req.url); - const pathname = url.pathname.replace('/api/auth', '') || '/'; - if (pathname === '/update-profile-image') { - const form = await req.formData(); - const file = Array.from(form.values()).find((v) => v instanceof File) as - | File - | undefined; - const auth = req.headers.get('authorization') || ''; - const token = - auth.replace('Bearer ', '') || req.headers.get('x-token') || ''; - const user = await service.verifyToken(token); - if (!file) - return NextResponse.json( - { success: false, message: 'No file' }, - { status: 400 } - ); - const res = await service.updateProfileImage(user.id, file); - return NextResponse.json(res); - } - if (pathname === '/update-profile') { - const auth = req.headers.get('authorization') || ''; - const token = - auth.replace('Bearer ', '') || req.headers.get('x-token') || ''; - const user = await service.verifyToken(token); - const body = await req.json(); - const res = await service.updateProfile(user.id, body); - return NextResponse.json(res); - } - return NextResponse.json({ error: 'Not found' }, { status: 404 }); - } catch (err: any) { - return NextResponse.json( - { success: false, message: String(err?.message ?? err) }, - { status: 500 } - ); - } -} - -export async function DELETE(req: NextRequest) { - try { - const url = new URL(req.url); - const pathname = url.pathname.replace('/api/auth', '') || '/'; - if (pathname === '/delete-profile-image') { - const auth = req.headers.get('authorization') || ''; - const token = - auth.replace('Bearer ', '') || req.headers.get('x-token') || ''; - const user = await service.verifyToken(token); - const res = await service.deleteProfileImage(user.id); - return NextResponse.json(res); - } - return NextResponse.json({ error: 'Not found' }, { status: 404 }); - } catch (err: any) { - return NextResponse.json( - { success: false, message: String(err?.message ?? err) }, - { status: 500 } - ); - } -} diff --git a/frontend/src/services/api.service.ts b/frontend/src/services/api.service.ts index a2da1ee9..c9c9b142 100644 --- a/frontend/src/services/api.service.ts +++ b/frontend/src/services/api.service.ts @@ -32,24 +32,19 @@ import type { } from '@/types'; import { getAuthToken as getCookieAuthToken } from '@/services/cookies.service'; -const apiEnv = process.env.NEXT_PUBLIC_API_URL; -const ocrEnv = process.env.NEXT_PUBLIC_OCR_API_URL; const appName = process.env.NEXT_PUBLIC_APP_NAME; const appVersion = process.env.NEXT_PUBLIC_APP_VERSION; const ocrMaxFileSize = process.env.NEXT_PUBLIC_OCR_MAX_FILE_SIZE; const ocrSupportedFormats = process.env.NEXT_PUBLIC_OCR_SUPPORTED_FORMATS; -if (!apiEnv) throw new Error('Missing NEXT_PUBLIC_API_URL'); -if (!ocrEnv) throw new Error('Missing NEXT_PUBLIC_OCR_API_URL'); if (!appName) throw new Error('Missing NEXT_PUBLIC_APP_NAME'); if (!appVersion) throw new Error('Missing NEXT_PUBLIC_APP_VERSION'); if (!ocrMaxFileSize) throw new Error('Missing NEXT_PUBLIC_OCR_MAX_FILE_SIZE'); if (!ocrSupportedFormats) throw new Error('Missing NEXT_PUBLIC_OCR_SUPPORTED_FORMATS'); -const API_BASE_URL: string = apiEnv; -const OCR_API_BASE_URL_RAW: string = ocrEnv; -const OCR_API_BASE_URL: string = OCR_API_BASE_URL_RAW.replace(/\/+$/g, ''); +const API_BASE_URL: string = ''; +const OCR_API_BASE_URL: string = '/api/ocr'; export const API_CONFIG = { BASE_URL: API_BASE_URL, @@ -71,7 +66,7 @@ const getAuthToken = (): string | undefined => { }; const axiosInstance: AxiosInstance = axios.create({ - baseURL: API_BASE_URL, + baseURL: '/api', timeout: API_CONFIG.TIMEOUT, headers: { 'Content-Type': 'application/json' }, }); @@ -106,32 +101,32 @@ const apiRequest = async ( /// Auth export const authAPI = { login: (credentials: LoginRequest): Promise => - apiRequest('/api/auth/login', { method: 'POST', data: credentials }), + apiRequest('/auth/login', { method: 'POST', data: credentials }), register: (userData: RegisterRequest): Promise => - apiRequest('/api/auth/register', { method: 'POST', data: userData }), - getCurrentUser: (): Promise => apiRequest('/api/auth/me'), + apiRequest('/auth/register', { method: 'POST', data: userData }), + getCurrentUser: (): Promise => apiRequest('/auth/me'), getUserById: (userId: string): Promise => - apiRequest(`/api/auth/users/${userId}`), - getAllUsers: (): Promise => apiRequest('/api/auth/users'), + apiRequest(`/auth/users/${userId}`), + getAllUsers: (): Promise => apiRequest('/auth/users'), uploadProfileImage: ( file: File ): Promise<{ success: boolean; message: string; image_url?: string }> => { const formData = new FormData(); formData.append('file', file); - return apiRequest('/api/auth/upload-profile-image', { + return apiRequest('/auth/upload-profile-image', { method: 'POST', data: formData, headers: { 'Content-Type': 'multipart/form-data' }, }); }, deleteProfileImage: (): Promise<{ success: boolean; message: string }> => - apiRequest('/api/auth/delete-profile-image', { method: 'DELETE' }), + apiRequest('/auth/delete-profile-image', { method: 'DELETE' }), updateProfileImage: ( file: File ): Promise<{ success: boolean; message: string; image_url?: string }> => { const formData = new FormData(); formData.append('file', file); - return apiRequest('/api/auth/update-profile-image', { + return apiRequest('/auth/update-profile-image', { method: 'PUT', data: formData, headers: { 'Content-Type': 'multipart/form-data' }, @@ -142,32 +137,32 @@ export const authAPI = { /// Products export const productsAPI = { create: (productData: CreateProductRequest): Promise => - apiRequest('/api/products/', { method: 'POST', data: productData }), + apiRequest('/products/', { method: 'POST', data: productData }), createBulk: ( productsData: CreateProductRequest[] ): Promise => - apiRequest('/api/products/bulk', { method: 'POST', data: productsData }), + apiRequest('/products/bulk', { method: 'POST', data: productsData }), getById: ( productId: string ): Promise<{ success: boolean; message: string; product: Product }> => - apiRequest(`/api/products/${productId}`), + apiRequest(`/products/${productId}`), update: ( productId: string, updates: UpdateProductRequest ): Promise<{ success: boolean; message: string; product: Product }> => - apiRequest(`/api/products/${productId}`, { method: 'PUT', data: updates }), + apiRequest(`/products/${productId}`, { method: 'PUT', data: updates }), delete: ( productId: string ): Promise<{ success: boolean; message: string; product: Product }> => - apiRequest(`/api/products/${productId}`, { method: 'DELETE' }), - list: (): Promise => apiRequest('/api/products/'), + apiRequest(`/products/${productId}`, { method: 'DELETE' }), + list: (): Promise => apiRequest('/products/'), addImages: ( productId: string, images: File[] ): Promise<{ success: boolean; message: string; product: Product }> => { const formData = new FormData(); images.forEach((image) => formData.append('images', image)); - return apiRequest(`/api/products/${productId}/images`, { + return apiRequest(`/products/${productId}/images`, { method: 'POST', data: formData, headers: { 'Content-Type': 'multipart/form-data' }, @@ -176,14 +171,14 @@ export const productsAPI = { getImages: ( productId: string ): Promise<{ success: boolean; images: string[] }> => - apiRequest(`/api/products/${productId}/images`), + apiRequest(`/products/${productId}/images`), updateImages: ( productId: string, images: File[] ): Promise<{ success: boolean; message: string; product: Product }> => { const formData = new FormData(); images.forEach((image) => formData.append('images', image)); - return apiRequest(`/api/products/${productId}/images`, { + return apiRequest(`/products/${productId}/images`, { method: 'PUT', data: formData, headers: { 'Content-Type': 'multipart/form-data' }, @@ -193,28 +188,28 @@ export const productsAPI = { productId: string, imageIndex: number ): Promise<{ success: boolean; message: string; product: Product }> => - apiRequest(`/api/products/${productId}/images/${imageIndex}`, { + apiRequest(`/products/${productId}/images/${imageIndex}`, { method: 'DELETE', }), }; export const getActiveProducts = async (): Promise => { - return apiRequest('/api/products/active'); + return apiRequest('/products/active'); }; export const getAllProducts = getActiveProducts; /// Inventory export const inventoryAPI = { - get: (): Promise => apiRequest('/api/inventory'), - getUser: (): Promise => apiRequest('/api/inventory/user'), + get: (): Promise => apiRequest('/inventory'), + getUser: (): Promise => apiRequest('/inventory/user'), getSummary: (): Promise<{ success: boolean; summary: InventorySummary }> => - apiRequest('/api/inventory/summary'), + apiRequest('/inventory/summary'), getLowStock: ( threshold?: number ): Promise<{ success: boolean; low_stock_products: InventoryProduct[] }> => apiRequest( - `/api/inventory/low-stock${ + `/inventory/low-stock${ threshold !== undefined ? `?threshold=${threshold}` : '' }` ), @@ -224,7 +219,7 @@ export const inventoryAPI = { success: boolean; message?: string; product?: InventoryProduct; - }> => apiRequest(`/api/inventory/${productId}`), + }> => apiRequest(`/inventory/${productId}`), }; const ocrAxios: AxiosInstance = axios.create({ @@ -278,48 +273,46 @@ export const ocrAPI = { /// Sales export const salesAPI = { create: (saleData: CreateSaleRequest): Promise => - apiRequest('/api/sales/', { method: 'POST', data: saleData }), - getById: (saleId: string): Promise => - apiRequest(`/api/sales/${saleId}`), - getHistory: (): Promise => apiRequest('/api/sales/'), + apiRequest('/sales/', { method: 'POST', data: saleData }), + getById: (saleId: string): Promise => apiRequest(`/sales/${saleId}`), + getHistory: (): Promise => apiRequest('/sales/'), }; export const profileAPI = { - get: (): Promise<{ success: boolean; user: User }> => - apiRequest('/api/profile'), + get: (): Promise<{ success: boolean; user: User }> => apiRequest('/profile'), update: ( profileData: Partial ): Promise<{ success: boolean; message: string; user: User }> => - apiRequest('/api/profile', { method: 'PUT', data: profileData }), + apiRequest('/profile', { method: 'PUT', data: profileData }), changePassword: (passwordData: { oldPassword: string; newPassword: string; }): Promise<{ success: boolean; message: string }> => - apiRequest('/api/profile/password', { method: 'PUT', data: passwordData }), + apiRequest('/profile/password', { method: 'PUT', data: passwordData }), }; /// Layouts export const layoutsAPI = { create: (layoutData: CreateLayoutRequest): Promise => - apiRequest('/api/layouts/', { method: 'POST', data: layoutData }), + apiRequest('/layouts/', { method: 'POST', data: layoutData }), getBySlug: (layoutSlug: string): Promise => - apiRequest(`/api/layouts/${layoutSlug}`), + apiRequest(`/layouts/${layoutSlug}`), update: ( layoutSlug: string, updates: UpdateLayoutRequest ): Promise => - apiRequest(`/api/layouts/${layoutSlug}`, { method: 'PUT', data: updates }), + apiRequest(`/layouts/${layoutSlug}`, { method: 'PUT', data: updates }), delete: (layoutSlug: string): Promise => - apiRequest(`/api/layouts/${layoutSlug}`, { method: 'DELETE' }), - list: (): Promise => apiRequest('/api/layouts/'), + apiRequest(`/layouts/${layoutSlug}`, { method: 'DELETE' }), + list: (): Promise => apiRequest('/layouts/'), listByOwner: (ownerId: string): Promise => - apiRequest(`/api/layouts/owner/${ownerId}`), + apiRequest(`/layouts/owner/${ownerId}`), listByInventory: (inventoryId: string): Promise => - apiRequest(`/api/layouts/inventory/${inventoryId}`), + apiRequest(`/layouts/inventory/${inventoryId}`), }; export const categoriesAPI = { - list: (): Promise => apiRequest('/api/categories/'), + list: (): Promise => apiRequest('/categories/'), getById: (id: string): Promise<{ success: boolean; category: Category }> => - apiRequest(`/api/categories/${id}`), + apiRequest(`/categories/${id}`), }; diff --git a/frontend/src/utils/crypto.ts b/frontend/src/utils/crypto.ts new file mode 100644 index 00000000..c460c6ae --- /dev/null +++ b/frontend/src/utils/crypto.ts @@ -0,0 +1,35 @@ +import crypto from 'crypto'; + +const ALGORITHM = 'aes-256-gcm'; + +function getKey(): Buffer { + const master = process.env.MASTER_KEY || ''; + return crypto.createHash('sha256').update(master).digest(); +} + +export function encryptString(plain: string): string { + const key = getKey(); + const iv = crypto.randomBytes(12); + const cipher = crypto.createCipheriv(ALGORITHM, key, iv); + const encrypted = Buffer.concat([ + cipher.update(plain, 'utf8'), + cipher.final(), + ]); + const tag = cipher.getAuthTag(); + return Buffer.concat([iv, tag, encrypted]).toString('base64'); +} + +export function decryptString(payload: string): string { + const raw = Buffer.from(payload, 'base64'); + const iv = raw.slice(0, 12); + const tag = raw.slice(12, 28); + const encrypted = raw.slice(28); + const key = getKey(); + const decipher = crypto.createDecipheriv(ALGORITHM, key, iv); + decipher.setAuthTag(tag); + const decrypted = Buffer.concat([ + decipher.update(encrypted), + decipher.final(), + ]); + return decrypted.toString('utf8'); +} From c2fd81c899da53b28793a42bffc96edbe13205cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?essp=C3=ADndola?= Date: Wed, 17 Sep 2025 13:58:13 -0300 Subject: [PATCH 12/35] Refactor authentication logic to support Supabase Auth fallback; ensure local user creation without storing passwords --- frontend/backend/auth/service.ts | 72 +++++++++++++++++++++++---- frontend/src/utils/supabase/server.ts | 5 +- 2 files changed, 66 insertions(+), 11 deletions(-) diff --git a/frontend/backend/auth/service.ts b/frontend/backend/auth/service.ts index 494d65d1..d3422783 100644 --- a/frontend/backend/auth/service.ts +++ b/frontend/backend/auth/service.ts @@ -36,19 +36,71 @@ export class AuthService { password: string ): Promise<{ user: UserItem; token: string }> { if (!email || !password) throw new Error('Email and password are required'); - const user = await this.repo.findByEmail(email); - if (!user) throw new Error('Invalid credentials'); - try { - const hashed = (user as any).password as string | undefined; - if (!hashed || !verifyPassword(password, hashed)) + // Try to find local user row + let user = await this.repo.findByEmail(email); + + // If we have a local user with a hashed password, verify locally. + if (user && (user as any).password) { + try { + const hashed = (user as any).password as string; + if (!verifyPassword(password, hashed)) + throw new Error('Invalid credentials'); + const userData: UserItem = { ...user }; + delete (userData as any).password; + const token = this.createAccessToken({ user_id: user.id }); + return { user: userData, token }; + } catch (e) { throw new Error('Invalid credentials'); + } + } + + // No local password available (or user missing) — try Supabase Auth fallback. + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; + const serviceKey = + process.env.SUPABASE_SERVICE_KEY || + process.env.SUPABASE_SERVICE_ROLE_KEY || + process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY; + if (!supabaseUrl || !serviceKey) throw new Error('Authentication error'); + try { + const res = await fetch( + `${supabaseUrl}/auth/v1/token?grant_type=password`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + apikey: serviceKey, + }, + body: JSON.stringify({ email, password }), + } + ); + const data = await res.json(); + if (!res.ok || data.error) throw new Error('Invalid credentials'); + const supaUser = data.user; + if (!supaUser) throw new Error('Invalid credentials'); + + // Ensure a local user row exists; create one without storing the password. + user = await this.repo.findByEmail(email); + if (!user) { + const fullName = + supaUser.user_metadata?.full_name || + supaUser.user_metadata?.name || + email; + const newId = await this.repo.createUser({ + full_name: fullName, + email, + password: null, + }); + user = await this.repo.findByUserId(String(newId)); + } + + if (!user) throw new Error('Authentication error'); + const userData: UserItem = { ...user }; + delete (userData as any).password; + const token = this.createAccessToken({ user_id: user.id }); + return { user: userData, token }; } catch (e) { - throw new Error('Authentication error'); + throw new Error('Invalid credentials'); } - const userData: UserItem = { ...user }; - delete (userData as any).password; - const token = this.createAccessToken({ user_id: user.id }); - return { user: userData, token }; } async register( diff --git a/frontend/src/utils/supabase/server.ts b/frontend/src/utils/supabase/server.ts index d313146b..f7f1bf61 100644 --- a/frontend/src/utils/supabase/server.ts +++ b/frontend/src/utils/supabase/server.ts @@ -2,7 +2,10 @@ import { createServerClient, type CookieOptions } from '@supabase/ssr'; import { cookies } from 'next/headers'; const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; -const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY; +const supabaseKey = + process.env.SUPABASE_SERVICE_KEY || + process.env.SUPABASE_SERVICE_ROLE_KEY || + process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY; export const createClient = async () => { const cookieStore = await cookies(); From 83a850b30cca82ad58fea52a3dd3484166c75b7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?essp=C3=ADndola?= Date: Wed, 17 Sep 2025 14:23:34 -0300 Subject: [PATCH 13/35] Refactor ProfilePage and ProfileForm components for consistency; update string quotes and remove unused fields --- frontend/src/app/profile/page.tsx | 74 ++++---- .../src/components/profile/ProfileForm.tsx | 158 ++++-------------- 2 files changed, 62 insertions(+), 170 deletions(-) diff --git a/frontend/src/app/profile/page.tsx b/frontend/src/app/profile/page.tsx index b1cac603..fbe4bac7 100644 --- a/frontend/src/app/profile/page.tsx +++ b/frontend/src/app/profile/page.tsx @@ -1,17 +1,17 @@ -"use client"; +'use client'; -import React, { useEffect, useState } from "react"; -import { useRouter } from "next/navigation"; -import Header from "@/components/profile/Header"; -import Sidebar from "@/components/profile/Sidebar"; -import AvatarUploader from "@/components/profile/AvatarUploader"; -import ProfileForm from "@/components/profile/ProfileForm"; -import { profileAPI, authAPI } from "@/services/api.service"; -import { useAuth } from "@/context/auth-store"; +import React, { useEffect, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import Header from '@/components/profile/Header'; +import Sidebar from '@/components/profile/Sidebar'; +import AvatarUploader from '@/components/profile/AvatarUploader'; +import ProfileForm from '@/components/profile/ProfileForm'; +import { profileAPI, authAPI } from '@/services/api.service'; +import { useAuth } from '@/context/auth-store'; const ProfilePage: React.FC = () => { const router = useRouter(); - const [activeSection, setActiveSection] = useState("profile"); + const [activeSection, setActiveSection] = useState('profile'); const [loading, setLoading] = useState(false); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); @@ -21,13 +21,8 @@ const ProfilePage: React.FC = () => { const { user, initialized, setUser } = useAuth(); const sections = [ - { id: "profile", name: "Profile" }, - { id: "personal", name: "Personal Data" }, - { id: "security", name: "Security" }, - { id: "preferences", name: "Preferences" }, - { id: "notifications", name: "Notifications" }, - { id: "billing", name: "Billing & Plan" }, - { id: "support", name: "Support & Help" }, + { id: 'profile', name: 'Profile' }, + { id: 'billing', name: 'Billing & Plan' }, ]; useEffect(() => { @@ -76,11 +71,6 @@ const ProfilePage: React.FC = () => { email: values.email, phone: values.phone, address: values.address, - language: values.language, - timezone: values.timezone, - dateFormat: values.dateFormat, - currency: values.currency, - twoFactorEnabled: values.twoFactorEnabled, }; const res = await profileAPI.update(payload as any); @@ -101,16 +91,16 @@ const ProfilePage: React.FC = () => { }; return ( -
+
router.push("/")} + onBack={() => router.push('/')} onSave={() => setSubmitSignal((s) => s + 1)} saving={saving} /> -
-
-
+
+
+
{ />
-
-
+
+
{sections.map((s) => ( ))}
-
-
-

+
+
+

{sections.find((s) => s.id === activeSection)?.name}

- {activeSection === "profile" ? ( -
-
+ {activeSection === 'profile' ? ( +
+
-
-

+
+

{profileData.full_name}

-

{profileData.email}

+

{profileData.email}

@@ -164,7 +154,7 @@ const ProfilePage: React.FC = () => { />
) : ( -
+
Section content moved to components.
)} diff --git a/frontend/src/components/profile/ProfileForm.tsx b/frontend/src/components/profile/ProfileForm.tsx index 200b7f21..40cdad35 100644 --- a/frontend/src/components/profile/ProfileForm.tsx +++ b/frontend/src/components/profile/ProfileForm.tsx @@ -1,19 +1,14 @@ -"use client"; +'use client'; -import React, { useEffect, useRef } from "react"; -import { Formik, Form, Field, ErrorMessage } from "formik"; -import * as Yup from "yup"; +import React, { useEffect, useRef } from 'react'; +import { Formik, Form, Field, ErrorMessage } from 'formik'; +import * as Yup from 'yup'; type Values = { full_name: string; email: string; phone?: string; address?: string; - language: string; - timezone: string; - dateFormat: string; - currency: string; - twoFactorEnabled: boolean; }; type Props = { @@ -23,8 +18,8 @@ type Props = { }; const schema = Yup.object().shape({ - full_name: Yup.string().required("Full name is required"), - email: Yup.string().email("Invalid email").required("Email is required"), + full_name: Yup.string().required('Full name is required'), + email: Yup.string().email('Invalid email').required('Email is required'), }); const ProfileForm: React.FC = ({ @@ -39,15 +34,10 @@ const ProfileForm: React.FC = ({ }, [submitSignal]); const defaults: Values = { - full_name: initialValues.full_name || "", - email: initialValues.email || "", - phone: initialValues.phone || "", - address: initialValues.address || "", - language: (initialValues.language as string) || "en", - timezone: (initialValues.timezone as string) || "America/New_York", - dateFormat: (initialValues.dateFormat as string) || "MM/DD/YYYY", - currency: (initialValues.currency as string) || "USD", - twoFactorEnabled: !!initialValues.twoFactorEnabled, + full_name: initialValues.full_name || '', + email: initialValues.email || '', + phone: initialValues.phone || '', + address: initialValues.address || '', }; return ( @@ -60,146 +50,58 @@ const ProfileForm: React.FC = ({ submitRef.current = formik.submitForm; return (
-
-
+
+
-
-
-
-
- - - - - - - -
- -
- - - - - - - -
- -
- - - - - - -
- -
- - - - - - - -
- -
- -
-
-
+ {/* two-factor removed per user request */} ); }} From 217f2cc2232660622d85370fe2199aa0e8098917 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?essp=C3=ADndola?= Date: Wed, 17 Sep 2025 15:15:34 -0300 Subject: [PATCH 14/35] Implement user deletion functionality and enhance profile image management; add API routes for user and image operations --- frontend/backend/auth/repository.ts | 12 + frontend/backend/auth/service.ts | 11 +- frontend/src/app/api/profile/image/route.ts | 86 ++++++++ frontend/src/app/api/profile/route.ts | 73 +++++++ frontend/src/app/profile/page.tsx | 205 +++++++++++++++--- .../src/components/profile/AvatarUploader.tsx | 144 +++++++++--- .../src/components/profile/ProfileForm.tsx | 8 +- frontend/src/services/api.service.ts | 21 +- frontend/src/types/index.ts | 1 + 9 files changed, 488 insertions(+), 73 deletions(-) create mode 100644 frontend/src/app/api/profile/image/route.ts create mode 100644 frontend/src/app/api/profile/route.ts diff --git a/frontend/backend/auth/repository.ts b/frontend/backend/auth/repository.ts index e85ea867..9dc07f22 100644 --- a/frontend/backend/auth/repository.ts +++ b/frontend/backend/auth/repository.ts @@ -98,4 +98,16 @@ export class UserRepository { return null; } } + + async deleteUser(user_id: string): Promise { + const supabase = await createClient(); + const { data, error } = await supabase + .from(this.table) + .delete() + .eq('id', user_id) + .select() + .single(); + if (error) throw error; + return (data as UserItem) || null; + } } diff --git a/frontend/backend/auth/service.ts b/frontend/backend/auth/service.ts index d3422783..82b9d3ed 100644 --- a/frontend/backend/auth/service.ts +++ b/frontend/backend/auth/service.ts @@ -36,10 +36,8 @@ export class AuthService { password: string ): Promise<{ user: UserItem; token: string }> { if (!email || !password) throw new Error('Email and password are required'); - // Try to find local user row let user = await this.repo.findByEmail(email); - // If we have a local user with a hashed password, verify locally. if (user && (user as any).password) { try { const hashed = (user as any).password as string; @@ -54,7 +52,6 @@ export class AuthService { } } - // No local password available (or user missing) — try Supabase Auth fallback. const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; const serviceKey = process.env.SUPABASE_SERVICE_KEY || @@ -78,7 +75,6 @@ export class AuthService { const supaUser = data.user; if (!supaUser) throw new Error('Invalid credentials'); - // Ensure a local user row exists; create one without storing the password. user = await this.repo.findByEmail(email); if (!user) { const fullName = @@ -213,4 +209,11 @@ export class AuthService { throw new Error(String(e?.message ?? e)); } } + + async deleteUser( + user_id: string + ): Promise<{ success: true; user: UserItem | null }> { + const user = await this.repo.deleteUser(user_id); + return { success: true, user }; + } } diff --git a/frontend/src/app/api/profile/image/route.ts b/frontend/src/app/api/profile/image/route.ts new file mode 100644 index 00000000..fbb7992b --- /dev/null +++ b/frontend/src/app/api/profile/image/route.ts @@ -0,0 +1,86 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { AuthService } from '@/../backend/auth/service'; + +const service = new AuthService(); + +async function getBearerToken(req: NextRequest) { + const header = req.headers.get('authorization') || ''; + if (!header) return null; + const parts = header.split(' '); + if (parts.length === 2 && parts[0].toLowerCase() === 'bearer') + return parts[1]; + return header; +} + +export async function POST(req: NextRequest) { + try { + const token = await getBearerToken(req); + if (!token) + return NextResponse.json( + { success: false, message: 'Missing token' }, + { status: 401 } + ); + const user = await service.verifyToken(token); + const form = await req.formData(); + const file = form.get('file') as File | null; + if (!file) + return NextResponse.json( + { success: false, message: 'Missing file' }, + { status: 400 } + ); + const res = await service.uploadProfileImage(String(user.id), file); + return NextResponse.json(res); + } catch (e: any) { + return NextResponse.json( + { success: false, message: String(e?.message ?? e) }, + { status: 500 } + ); + } +} + +export async function PATCH(req: NextRequest) { + try { + const token = await getBearerToken(req); + if (!token) + return NextResponse.json( + { success: false, message: 'Missing token' }, + { status: 401 } + ); + const user = await service.verifyToken(token); + const form = await req.formData(); + const file = form.get('file') as File | null; + if (!file) + return NextResponse.json( + { success: false, message: 'Missing file' }, + { status: 400 } + ); + const res = await service.updateProfileImage(String(user.id), file); + return NextResponse.json(res); + } catch (e: any) { + return NextResponse.json( + { success: false, message: String(e?.message ?? e) }, + { status: 500 } + ); + } +} + +export async function DELETE(req: NextRequest) { + try { + const token = await getBearerToken(req); + if (!token) + return NextResponse.json( + { success: false, message: 'Missing token' }, + { status: 401 } + ); + const user = await service.verifyToken(token); + const res = await service.deleteProfileImage(String(user.id)); + return NextResponse.json(res); + } catch (e: any) { + return NextResponse.json( + { success: false, message: String(e?.message ?? e) }, + { status: 500 } + ); + } +} + +export const runtime = 'nodejs'; diff --git a/frontend/src/app/api/profile/route.ts b/frontend/src/app/api/profile/route.ts new file mode 100644 index 00000000..68ec837c --- /dev/null +++ b/frontend/src/app/api/profile/route.ts @@ -0,0 +1,73 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { AuthService } from '@/../backend/auth/service'; + +const service = new AuthService(); + +async function getBearerToken(req: NextRequest) { + const header = req.headers.get('authorization') || ''; + if (!header) return null; + const parts = header.split(' '); + if (parts.length === 2 && parts[0].toLowerCase() === 'bearer') + return parts[1]; + return header; +} + +export async function GET(req: NextRequest) { + try { + const token = await getBearerToken(req); + if (!token) + return NextResponse.json( + { success: false, message: 'Missing token' }, + { status: 401 } + ); + const user = await service.verifyToken(token); + const out = await service.getProfileUser(String(user.id)); + return NextResponse.json(out); + } catch (e: any) { + return NextResponse.json( + { success: false, message: String(e?.message ?? e) }, + { status: 401 } + ); + } +} + +export async function PATCH(req: NextRequest) { + try { + const token = await getBearerToken(req); + if (!token) + return NextResponse.json( + { success: false, message: 'Missing token' }, + { status: 401 } + ); + const user = await service.verifyToken(token); + const body = await req.json(); + const res = await service.updateProfile(String(user.id), body || {}); + return NextResponse.json(res); + } catch (e: any) { + return NextResponse.json( + { success: false, message: String(e?.message ?? e) }, + { status: 500 } + ); + } +} + +export async function DELETE(req: NextRequest) { + try { + const token = await getBearerToken(req); + if (!token) + return NextResponse.json( + { success: false, message: 'Missing token' }, + { status: 401 } + ); + const user = await service.verifyToken(token); + const res = await service.deleteUser(String(user.id)); + return NextResponse.json(res); + } catch (e: any) { + return NextResponse.json( + { success: false, message: String(e?.message ?? e) }, + { status: 500 } + ); + } +} + +export const runtime = 'nodejs'; diff --git a/frontend/src/app/profile/page.tsx b/frontend/src/app/profile/page.tsx index fbe4bac7..dfa333b5 100644 --- a/frontend/src/app/profile/page.tsx +++ b/frontend/src/app/profile/page.tsx @@ -5,7 +5,6 @@ import { useRouter } from 'next/navigation'; import Header from '@/components/profile/Header'; import Sidebar from '@/components/profile/Sidebar'; import AvatarUploader from '@/components/profile/AvatarUploader'; -import ProfileForm from '@/components/profile/ProfileForm'; import { profileAPI, authAPI } from '@/services/api.service'; import { useAuth } from '@/context/auth-store'; @@ -15,9 +14,12 @@ const ProfilePage: React.FC = () => { const [loading, setLoading] = useState(false); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); + const [success, setSuccess] = useState(null); const [profileData, setProfileData] = useState>({}); - const [avatarFile, setAvatarFile] = useState(null); - const [submitSignal, setSubmitSignal] = useState(0); + const [editingFields, setEditingFields] = useState>( + {} + ); + const [editValues, setEditValues] = useState>({}); const { user, initialized, setUser } = useAuth(); const sections = [ @@ -47,7 +49,6 @@ const ProfilePage: React.FC = () => { } } } catch (err) { - // non-critical } finally { if (!canceled) setLoading(false); } @@ -62,26 +63,41 @@ const ProfilePage: React.FC = () => { }; }, [initialized, user, setUser]); - const handleSave = async (values: Record) => { + const handleSave = async () => { setSaving(true); setError(null); + setSuccess(null); try { - const payload: Record = { - full_name: values.full_name, - email: values.email, - phone: values.phone, - address: values.address, - }; + const payload: Record = {}; + + Object.keys(editValues).forEach((field) => { + const newValue = editValues[field]?.trim(); + const oldValue = profileData[field]; + + if (newValue !== oldValue) { + if (field === 'phone' || field === 'polar_api_key') { + payload[field] = newValue || null; + } else if (newValue) { + payload[field] = newValue; + } + } + }); + + if (Object.keys(payload).length === 0) { + setSuccess('No changes to save'); + setTimeout(() => setSuccess(null), 3000); + return; + } const res = await profileAPI.update(payload as any); if (res && (res as any).user) { const updated = (res as any).user; setProfileData(updated); - try { - setUser(updated as any); - } catch { - // ignore if store setUser fails - } + setUser(updated as any); + setEditingFields({}); + setEditValues({}); + setSuccess('Profile updated successfully'); + setTimeout(() => setSuccess(null), 3000); } } catch (err) { setError(err instanceof Error ? err.message : String(err)); @@ -90,11 +106,110 @@ const ProfilePage: React.FC = () => { } }; + const handleImageUpdated = async ( + newImageUrl: string | null, + updatedUser?: any + ) => { + if (updatedUser) { + setProfileData(updatedUser); + setUser(updatedUser as any); + } else { + const updated = { ...profileData, profile_image: newImageUrl }; + setProfileData(updated); + setUser(updated as any); + } + setSuccess('Avatar updated successfully'); + setTimeout(() => setSuccess(null), 3000); + }; + + const handleError = (errorMsg: string) => { + setError(errorMsg); + setTimeout(() => setError(null), 5000); + }; + + const startEditing = (field: string) => { + setEditingFields((prev) => ({ ...prev, [field]: true })); + setEditValues((prev) => ({ ...prev, [field]: profileData[field] || '' })); + }; + + const cancelEditing = (field: string) => { + setEditingFields((prev) => ({ ...prev, [field]: false })); + setEditValues((prev) => { + const newValues = { ...prev }; + delete newValues[field]; + return newValues; + }); + }; + + const updateEditValue = (field: string, value: string) => { + setEditValues((prev) => ({ ...prev, [field]: value })); + }; + + const hasChanges = Object.keys(editingFields).some( + (field) => editingFields[field] + ); + + const renderField = (field: string, label: string, type: string = 'text') => { + const isEditing = editingFields[field]; + const currentValue = profileData[field] || ''; + const editValue = editValues[field] || ''; + + return ( +
+
+ + {!isEditing && ( + + )} +
+ + {isEditing ? ( +
+ updateEditValue(field, e.target.value)} + className='w-full p-3 border rounded-lg border-[#CBD5E1] focus:ring-2 focus:ring-[#F6DE91] focus:border-[#A94D14] bg-[#FFFFFF] text-[#000000]' + placeholder={`Enter ${label.toLowerCase()}`} + /> +
+ +
+
+ ) : ( +
+ {field === 'polar_api_key' ? ( + currentValue ? ( + '*******' + ) : ( + Not set + ) + ) : ( + currentValue || ( + Not set + ) + )} +
+ )} +
+ ); + }; + return (
router.push('/')} - onSave={() => setSubmitSignal((s) => s + 1)} + onSave={hasChanges ? () => handleSave() : () => {}} saving={saving} /> @@ -132,26 +247,64 @@ const ProfilePage: React.FC = () => { {sections.find((s) => s.id === activeSection)?.name}

+ {error && ( +
+ {error} +
+ )} + + {success && ( +
+ {success} +
+ )} + {activeSection === 'profile' ? (

- {profileData.full_name} + {profileData.full_name || 'User'}

-

{profileData.email}

+

+ {profileData.email || ''} +

- +
+

+ Profile Information +

+ +
+ {renderField('full_name', 'Full Name')} + {renderField('email', 'Email', 'email')} + {renderField('phone', 'Phone')} + {renderField( + 'polar_api_key', + 'Polar API Key', + 'password' + )} +
+ + {hasChanges && ( +
+ +
+ )} +
) : (
diff --git a/frontend/src/components/profile/AvatarUploader.tsx b/frontend/src/components/profile/AvatarUploader.tsx index d96199ab..cd852c61 100644 --- a/frontend/src/components/profile/AvatarUploader.tsx +++ b/frontend/src/components/profile/AvatarUploader.tsx @@ -1,16 +1,76 @@ -import React from 'react'; +import React, { useState } from 'react'; +import { authAPI } from '@/services/api.service'; type Props = { imageUrl?: string | null; - onChange: (file: File | null) => void; + onImageUpdated: (newImageUrl: string | null, updatedUser?: any) => void; + onError?: (error: string) => void; }; -const AvatarUploader: React.FC = ({ imageUrl, onChange }) => { +const AvatarUploader: React.FC = ({ + imageUrl, + onImageUpdated, + onError, +}) => { + const [uploading, setUploading] = useState(false); + + const handleFileChange = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + if (file.size > 5 * 1024 * 1024) { + onError?.('File size must be less than 5MB'); + return; + } + + if (!file.type.startsWith('image/')) { + onError?.('Please select an image file'); + return; + } + + setUploading(true); + try { + const res = await authAPI.uploadProfileImage(file); + if (res.success && res.user && res.user.profile_image) { + onImageUpdated(res.user.profile_image, res.user); + } else if (res.success) { + onImageUpdated(null, res.user); + } else { + onError?.('Upload failed'); + } + } catch (err) { + onError?.(err instanceof Error ? err.message : 'Upload failed'); + } finally { + setUploading(false); + } + }; + + const handleDeleteImage = async () => { + if (!imageUrl) return; + setUploading(true); + try { + const res = await authAPI.deleteProfileImage(); + if (res.success) { + onImageUpdated(null, res.user); + } else { + onError?.('Delete failed'); + } + } catch (err) { + onError?.(err instanceof Error ? err.message : 'Delete failed'); + } finally { + setUploading(false); + } + }; + return ( -
+
-
- {imageUrl ? ( +
+ {uploading ? ( +
+
+
+ ) : imageUrl ? ( avatar = ({ imageUrl, onChange }) => { )}
- + )} + {imageUrl && !uploading && ( + + )}
- Change avatar + {uploading ? 'Uploading...' : 'Change avatar'}
PNG, JPG up to 5MB
diff --git a/frontend/src/components/profile/ProfileForm.tsx b/frontend/src/components/profile/ProfileForm.tsx index 40cdad35..a4de9529 100644 --- a/frontend/src/components/profile/ProfileForm.tsx +++ b/frontend/src/components/profile/ProfileForm.tsx @@ -18,8 +18,10 @@ type Props = { }; const schema = Yup.object().shape({ - full_name: Yup.string().required('Full name is required'), - email: Yup.string().email('Invalid email').required('Email is required'), + full_name: Yup.string(), + email: Yup.string().email('Invalid email'), + phone: Yup.string(), + address: Yup.string(), }); const ProfileForm: React.FC = ({ @@ -100,8 +102,6 @@ const ProfileForm: React.FC = ({
- - {/* two-factor removed per user request */} ); }} diff --git a/frontend/src/services/api.service.ts b/frontend/src/services/api.service.ts index c9c9b142..ca52f31a 100644 --- a/frontend/src/services/api.service.ts +++ b/frontend/src/services/api.service.ts @@ -110,24 +110,27 @@ export const authAPI = { getAllUsers: (): Promise => apiRequest('/auth/users'), uploadProfileImage: ( file: File - ): Promise<{ success: boolean; message: string; image_url?: string }> => { + ): Promise<{ success: boolean; user?: any; message?: string }> => { const formData = new FormData(); formData.append('file', file); - return apiRequest('/auth/upload-profile-image', { + return apiRequest('/profile/image', { method: 'POST', data: formData, headers: { 'Content-Type': 'multipart/form-data' }, }); }, - deleteProfileImage: (): Promise<{ success: boolean; message: string }> => - apiRequest('/auth/delete-profile-image', { method: 'DELETE' }), + deleteProfileImage: (): Promise<{ + success: boolean; + user?: any; + message?: string; + }> => apiRequest('/profile/image', { method: 'DELETE' }), updateProfileImage: ( file: File - ): Promise<{ success: boolean; message: string; image_url?: string }> => { + ): Promise<{ success: boolean; user?: any; message?: string }> => { const formData = new FormData(); formData.append('file', file); - return apiRequest('/auth/update-profile-image', { - method: 'PUT', + return apiRequest('/profile/image', { + method: 'PATCH', data: formData, headers: { 'Content-Type': 'multipart/form-data' }, }); @@ -283,12 +286,14 @@ export const profileAPI = { update: ( profileData: Partial ): Promise<{ success: boolean; message: string; user: User }> => - apiRequest('/profile', { method: 'PUT', data: profileData }), + apiRequest('/profile', { method: 'PATCH', data: profileData }), changePassword: (passwordData: { oldPassword: string; newPassword: string; }): Promise<{ success: boolean; message: string }> => apiRequest('/profile/password', { method: 'PUT', data: passwordData }), + deleteUser: (): Promise<{ success: boolean; user?: User }> => + apiRequest('/profile', { method: 'DELETE' }), }; /// Layouts diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 57784093..47d23214 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -20,6 +20,7 @@ export interface User { email: string; full_name: string; phone?: string; + polar_api_key?: string; role: RoleUser; profile_image?: string; created_at?: string; From 03c61e5c8a64aa82f33c590bc72df46035538bd7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?essp=C3=ADndola?= Date: Wed, 17 Sep 2025 15:55:37 -0300 Subject: [PATCH 15/35] Refactor product API integration and enhance product management; implement Polar API handling and encryption for secure data transactions --- frontend/src/app/api/products/route.ts | 193 +++++++++---------------- frontend/src/app/home/page.tsx | 42 +++++- frontend/src/services/api.service.ts | 73 +++------- frontend/src/types/polar.ts | 57 ++++++++ frontend/src/utils/crypto.utils.ts | 48 ++++++ frontend/src/utils/polar.utils.ts | 105 ++++++++++++++ 6 files changed, 338 insertions(+), 180 deletions(-) create mode 100644 frontend/src/types/polar.ts create mode 100644 frontend/src/utils/crypto.utils.ts create mode 100644 frontend/src/utils/polar.utils.ts diff --git a/frontend/src/app/api/products/route.ts b/frontend/src/app/api/products/route.ts index a4610b0f..68fb4cbb 100644 --- a/frontend/src/app/api/products/route.ts +++ b/frontend/src/app/api/products/route.ts @@ -1,164 +1,115 @@ import { NextRequest, NextResponse } from 'next/server'; -import { ProductService } from '@/../backend/back/product/service'; -import { uploadMultipleImagesFromFiles } from '@/utils/cloudinary'; +import { polarAPI } from '@/utils/polar.utils'; +import { AuthService } from '@/../backend/auth/service'; -const service = new ProductService(); +async function getCurrentUser(req: NextRequest) { + const authHeader = req.headers.get('authorization'); + if (!authHeader || !authHeader.startsWith('Bearer ')) { + throw new Error('Missing authentication token'); + } + + const token = authHeader.split(' ')[1]; + const authService = new AuthService(); -export async function POST(req: NextRequest) { try { - const url = new URL(req.url); - const pathname = url.pathname.replace('/api/products', '') || '/'; - if (pathname === '/' || pathname === '') { - const body = await req.json(); - const userId = req.headers.get('x-user-id') || ''; - const product = await service.createProduct(body, userId); - return NextResponse.json({ success: true, product }); - } - if (pathname === '/bulk') { - const body = await req.json(); - const userId = req.headers.get('x-user-id') || ''; - const results: any[] = []; - for (let i = 0; i < body.length; i++) { - try { - const p = await service.createProduct(body[i], userId); - results.push(p); - } catch (e: any) { - const errStr = String(e?.message ?? e); - if ( - errStr.includes('duplicate') || - errStr.includes('duplicate key') - ) { - try { - const modified = { - ...body[i], - sku: `${body[i].sku || 'sku'}_${i + 1}`, - }; - const p2 = await service.createProduct(modified, userId); - results.push(p2); - } catch (e2) { - continue; - } - } else { - continue; - } - } - } - return NextResponse.json(results); - } - return NextResponse.json({ error: 'Not found' }, { status: 404 }); - } catch (err: any) { - return NextResponse.json( - { success: false, message: String(err?.message ?? err) }, - { status: 500 } - ); + const user = await authService.verifyToken(token); + const profile = await authService.getProfileUser(String(user.id)); + + const polarApiKey = + profile.user?.polar_api_key || process.env.POLAR_ACCESS_TOKEN || ''; + + return { + userId: user.id, + userEmail: user.email, + polarApiKey: polarApiKey, + }; + } catch (error) { + throw new Error('Invalid authentication'); } } export async function GET(req: NextRequest) { try { + const { polarApiKey } = await getCurrentUser(req); const url = new URL(req.url); - const pathname = url.pathname.replace('/api/products', '') || '/'; - if (pathname === '/' || pathname === '') { - const userId = req.headers.get('x-user-id') || ''; - const products = await service.listProducts(userId); + const productId = url.searchParams.get('id'); + const organizationId = url.searchParams.get('organization_id') || undefined; + + if (productId) { + const product = await polarAPI.getProduct(polarApiKey, productId); + return NextResponse.json({ success: true, product }); + } else { + const products = await polarAPI.listProducts(polarApiKey, organizationId); return NextResponse.json({ success: true, products }); } - const parts = pathname.split('/').filter(Boolean); - if (parts[0] === 'active') { - const userId = req.headers.get('x-user-id') || ''; - const products = await service.listProducts(userId); - const active = products.filter( - (p) => String(p.status || '').toLowerCase() === 'active' - ); - return NextResponse.json({ success: true, products: active }); - } - if (parts[0]) { - const product = await service.getProduct(parts[0]); - return NextResponse.json({ - success: true, - message: 'Product found', - product, - }); - } - return NextResponse.json({ error: 'Not found' }, { status: 404 }); - } catch (err: any) { + } catch (error: any) { return NextResponse.json( - { success: false, message: String(err?.message ?? err) }, + { success: false, message: error.message || 'Failed to fetch products' }, { status: 500 } ); } } -export async function PUT(req: NextRequest) { +export async function POST(req: NextRequest) { try { - const url = new URL(req.url); - const pathname = url.pathname.replace('/api/products', '') || '/'; - const parts = pathname.split('/').filter(Boolean); - if (parts[0]) { - const body = await req.json(); - const userTimezone = req.headers.get('x-user-timezone') || 'UTC'; - const updated = await service.updateProduct(parts[0], body, userTimezone); - return NextResponse.json({ - success: true, - message: 'Product updated successfully', - product: updated, - }); - } - return NextResponse.json({ error: 'Not found' }, { status: 404 }); - } catch (err: any) { + const { polarApiKey } = await getCurrentUser(req); + const body: any = await req.json(); + + const product = await polarAPI.createProduct(polarApiKey, body); + return NextResponse.json({ success: true, product }); + } catch (error: any) { return NextResponse.json( - { success: false, message: String(err?.message ?? err) }, + { success: false, message: error.message || 'Failed to create product' }, { status: 500 } ); } } -export async function DELETE(req: NextRequest) { +export async function PUT(req: NextRequest) { try { + const { polarApiKey } = await getCurrentUser(req); const url = new URL(req.url); - const pathname = url.pathname.replace('/api/products', '') || '/'; - const parts = pathname.split('/').filter(Boolean); - if (parts[0]) { - const deleted = await service.deleteProduct(parts[0]); - return NextResponse.json({ - success: true, - message: 'Product deleted successfully', - product: deleted, - }); + const productId = url.searchParams.get('id'); + + if (!productId) { + return NextResponse.json( + { success: false, message: 'Product ID is required' }, + { status: 400 } + ); } - return NextResponse.json({ error: 'Not found' }, { status: 404 }); - } catch (err: any) { + + const body: any = await req.json(); + const product = await polarAPI.updateProduct(polarApiKey, productId, body); + return NextResponse.json({ success: true, product }); + } catch (error: any) { return NextResponse.json( - { success: false, message: String(err?.message ?? err) }, + { success: false, message: error.message || 'Failed to update product' }, { status: 500 } ); } } -export async function POST_images(req: NextRequest) { +export async function DELETE(req: NextRequest) { try { - const contentType = req.headers.get('content-type') || ''; - if (!contentType.includes('multipart/form-data')) { + const { polarApiKey } = await getCurrentUser(req); + const url = new URL(req.url); + const productId = url.searchParams.get('id'); + + if (!productId) { return NextResponse.json( - { success: false, message: 'Invalid content type' }, + { success: false, message: 'Product ID is required' }, { status: 400 } ); } - const form = await req.formData(); - const files: File[] = []; - for (const [key, value] of form.entries()) { - if (value instanceof File) files.push(value as File); - } - if (files.length === 0) - return NextResponse.json( - { success: false, message: 'No files provided' }, - { status: 400 } - ); - const urls = await uploadMultipleImagesFromFiles(files); - return NextResponse.json({ success: true, urls }); - } catch (err: any) { + + await polarAPI.deleteProduct(polarApiKey, productId); + return NextResponse.json({ + success: true, + message: 'Product deleted successfully', + }); + } catch (error: any) { return NextResponse.json( - { success: false, message: String(err?.message ?? err) }, + { success: false, message: error.message || 'Failed to delete product' }, { status: 500 } ); } diff --git a/frontend/src/app/home/page.tsx b/frontend/src/app/home/page.tsx index 3148ac92..a62e9738 100644 --- a/frontend/src/app/home/page.tsx +++ b/frontend/src/app/home/page.tsx @@ -65,8 +65,25 @@ const HomePage: React.FC = ({ if (response && response.success && Array.isArray(response.products)) { const availableProducts = response.products.map((product: any) => { return { - ...product, - stock: Number(product.stock ?? 0), + id: product.id, + name: product.name || 'Unnamed Product', + description: product.description || '', + price: product.prices?.[0]?.price_amount + ? product.prices[0].price_amount / 100 + : 0, + stock: 1, + min_stock: 0, + category_ids: [], + images: [], + status: 'active' as any, + weight: 0, + sku: product.id, + creator_id: '', + unit: 'Per item' as any, + product_type: 'Physical Product' as any, + localization: '', + created_at: product.created_at || new Date().toISOString(), + last_updated: product.modified_at || new Date().toISOString(), } as Product; }); setProducts(availableProducts); @@ -265,8 +282,25 @@ const HomePage: React.FC = ({ if (response && response.success && Array.isArray(response.products)) { const availableProducts = response.products.map((product: any) => { return { - ...product, - stock: Number(product.stock ?? 0), + id: product.id, + name: product.name || 'Unnamed Product', + description: product.description || '', + price: product.prices?.[0]?.price_amount + ? product.prices[0].price_amount / 100 + : 0, + stock: 1, + min_stock: 0, + category_ids: [], + images: [], + status: 'active' as any, + weight: 0, + sku: product.id, + creator_id: '', + unit: 'Per item' as any, + product_type: 'Physical Product' as any, + localization: '', + created_at: product.created_at || new Date().toISOString(), + last_updated: product.modified_at || new Date().toISOString(), } as Product; }); setProducts(availableProducts); diff --git a/frontend/src/services/api.service.ts b/frontend/src/services/api.service.ts index ca52f31a..a2fdf95b 100644 --- a/frontend/src/services/api.service.ts +++ b/frontend/src/services/api.service.ts @@ -139,65 +139,28 @@ export const authAPI = { /// Products export const productsAPI = { - create: (productData: CreateProductRequest): Promise => - apiRequest('/products/', { method: 'POST', data: productData }), - createBulk: ( - productsData: CreateProductRequest[] - ): Promise => - apiRequest('/products/bulk', { method: 'POST', data: productsData }), - getById: ( - productId: string - ): Promise<{ success: boolean; message: string; product: Product }> => - apiRequest(`/products/${productId}`), - update: ( - productId: string, - updates: UpdateProductRequest - ): Promise<{ success: boolean; message: string; product: Product }> => - apiRequest(`/products/${productId}`, { method: 'PUT', data: updates }), - delete: ( - productId: string - ): Promise<{ success: boolean; message: string; product: Product }> => - apiRequest(`/products/${productId}`, { method: 'DELETE' }), - list: (): Promise => apiRequest('/products/'), - addImages: ( - productId: string, - images: File[] - ): Promise<{ success: boolean; message: string; product: Product }> => { - const formData = new FormData(); - images.forEach((image) => formData.append('images', image)); - return apiRequest(`/products/${productId}/images`, { + create: (productData: any, organizationId?: string): Promise => + apiRequest('/products/', { method: 'POST', - data: formData, - headers: { 'Content-Type': 'multipart/form-data' }, - }); - }, - getImages: ( - productId: string - ): Promise<{ success: boolean; images: string[] }> => - apiRequest(`/products/${productId}/images`), - updateImages: ( - productId: string, - images: File[] - ): Promise<{ success: boolean; message: string; product: Product }> => { - const formData = new FormData(); - images.forEach((image) => formData.append('images', image)); - return apiRequest(`/products/${productId}/images`, { - method: 'PUT', - data: formData, - headers: { 'Content-Type': 'multipart/form-data' }, - }); - }, - deleteImage: ( - productId: string, - imageIndex: number - ): Promise<{ success: boolean; message: string; product: Product }> => - apiRequest(`/products/${productId}/images/${imageIndex}`, { - method: 'DELETE', + data: { ...productData, organization_id: organizationId }, }), + getById: (productId: string): Promise => + apiRequest(`/products/?id=${productId}`), + update: (productId: string, updates: any): Promise => + apiRequest(`/products/?id=${productId}`, { method: 'PUT', data: updates }), + delete: (productId: string): Promise => + apiRequest(`/products/?id=${productId}`, { method: 'DELETE' }), + list: (organizationId?: string): Promise => { + const params = organizationId ? `?organization_id=${organizationId}` : ''; + return apiRequest(`/products/${params}`); + }, }; -export const getActiveProducts = async (): Promise => { - return apiRequest('/products/active'); +export const getActiveProducts = async ( + organizationId?: string +): Promise => { + const params = organizationId ? `?organization_id=${organizationId}` : ''; + return apiRequest(`/products/${params}`); }; export const getAllProducts = getActiveProducts; diff --git a/frontend/src/types/polar.ts b/frontend/src/types/polar.ts new file mode 100644 index 00000000..4e639a0e --- /dev/null +++ b/frontend/src/types/polar.ts @@ -0,0 +1,57 @@ +export interface PolarProductPrice { + amountType: 'fixed' | 'free' | 'pay_what_you_want'; + priceAmount?: number; + priceCurrency?: string; +} + +export interface PolarProductMedia { + id: string; + url: string; + type: 'image' | 'video'; +} + +export interface PolarProduct { + id: string; + name: string; + description?: string | null; + isArchived?: boolean; + isRecurring?: boolean; + organizationId: string; + prices?: PolarProductPrice[]; + benefits?: string[]; + media?: PolarProductMedia[]; + createdAt: string; + modifiedAt?: string; +} + +export interface PolarProductsListParams { + organizationId?: string; + isArchived?: boolean; + isRecurring?: boolean; + query?: string; + page?: number; + limit?: number; +} + +export interface PolarProductsListResponsePage { + items: PolarProduct[]; + nextPage?: number | null; + prevPage?: number | null; +} + +export interface PolarProductCreateBody { + name: string; + description?: string | null; + organizationId: string; + recurringInterval?: 'month' | 'year' | null; + prices?: PolarProductPrice[]; + benefits?: string[]; +} + +export interface PolarProductUpdateBody { + name?: string; + description?: string | null; + prices?: PolarProductPrice[]; + benefits?: string[]; + isArchived?: boolean; +} diff --git a/frontend/src/utils/crypto.utils.ts b/frontend/src/utils/crypto.utils.ts new file mode 100644 index 00000000..d9942bcd --- /dev/null +++ b/frontend/src/utils/crypto.utils.ts @@ -0,0 +1,48 @@ +import crypto from 'crypto'; + +const ALGORITHM = 'aes-256-cbc'; +const MASTER_KEY = process.env.MASTER_KEY || 'f8d3a9c7b6e4f1d2a5c8e0b9d7f6a3c2'; + +export function decryptData(encryptedData: string): string { + try { + const parts = encryptedData.split(':'); + if (parts.length !== 2) { + return encryptedData; + } + + const [ivHex, encryptedHex] = parts; + const iv = Buffer.from(ivHex, 'hex'); + const encrypted = Buffer.from(encryptedHex, 'hex'); + + const decipher = crypto.createDecipheriv( + ALGORITHM, + Buffer.from(MASTER_KEY, 'hex'), + iv + ); + let decrypted = decipher.update(encrypted, undefined, 'utf8'); + decrypted += decipher.final('utf8'); + + return decrypted; + } catch (error) { + console.error('Error decrypting data:', error); + return encryptedData; + } +} + +export function encryptData(data: string): string { + try { + const iv = crypto.randomBytes(16); + const cipher = crypto.createCipheriv( + ALGORITHM, + Buffer.from(MASTER_KEY, 'hex'), + iv + ); + let encrypted = cipher.update(data, 'utf8', 'hex'); + encrypted += cipher.final('hex'); + + return `${iv.toString('hex')}:${encrypted}`; + } catch (error) { + console.error('Error encrypting data:', error); + return data; + } +} diff --git a/frontend/src/utils/polar.utils.ts b/frontend/src/utils/polar.utils.ts new file mode 100644 index 00000000..6d09fd9b --- /dev/null +++ b/frontend/src/utils/polar.utils.ts @@ -0,0 +1,105 @@ +import { Polar } from '@polar-sh/sdk'; +import { decryptData } from './crypto.utils'; + +export const decryptApiKey = (encryptedKey: string): string => { + if (!encryptedKey || encryptedKey.trim() === '') { + return process.env.POLAR_ACCESS_TOKEN || ''; + } + + const decrypted = decryptData(encryptedKey); + if (!decrypted || decrypted === encryptedKey) { + return process.env.POLAR_ACCESS_TOKEN || ''; + } + + return decrypted; +}; + +export const polarAPI = { + async listProducts(apiKey: string, organizationId?: string): Promise { + const client = new Polar({ + accessToken: decryptApiKey(apiKey), + }); + + const listArgs: any = {}; + const uuidRegex = + /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$/; + if (organizationId && uuidRegex.test(organizationId)) { + listArgs.organizationId = organizationId; + } + + const result = await client.products.list(listArgs); + + const products: any[] = []; + for await (const page of result) { + const items = (page as any)?.items ?? page; + if (Array.isArray(items)) { + products.push(...items); + } + } + return products; + }, + + async getProduct(apiKey: string, productId: string): Promise { + const client = new Polar({ + accessToken: decryptApiKey(apiKey), + }); + + const result = await client.products.get({ + id: productId, + }); + return result; + }, + + async createProduct(apiKey: string, productData: any): Promise { + const client = new Polar({ + accessToken: decryptApiKey(apiKey), + }); + + const result = await client.products.create({ + name: productData.name, + description: productData.description, + organizationId: productData.organization_id, + recurringInterval: productData.recurringInterval || 'month', + prices: productData.prices || [{ amountType: 'free' }], + }); + return result; + }, + + async updateProduct( + apiKey: string, + productId: string, + productData: any + ): Promise { + const client = new Polar({ + accessToken: decryptApiKey(apiKey), + }); + + const result = await client.products.update({ + id: productId, + productUpdate: { + name: productData.name, + description: productData.description, + prices: productData.prices, + isArchived: productData.isArchived, + }, + }); + return result; + }, + + async deleteProduct(apiKey: string, productId: string): Promise { + const client = new Polar({ + accessToken: decryptApiKey(apiKey), + }); + + try { + await client.products.update({ + id: productId, + productUpdate: { + isArchived: true, + }, + }); + } catch (error) { + throw new Error('Failed to archive product'); + } + }, +}; From db27ab81b49a52b2e52cbc067e4153fee28d9860 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?essp=C3=ADndola?= Date: Wed, 17 Sep 2025 17:31:23 -0300 Subject: [PATCH 16/35] Refactor product management API; change DELETE to PATCH for archiving products, update product data handling, and enhance error messaging --- frontend/src/app/api/products/route.ts | 11 +- frontend/src/app/home/page.tsx | 133 ++++++---------- frontend/src/components/ProductCard.tsx | 6 +- frontend/src/components/home/ProductGrid.tsx | 2 +- frontend/src/services/api.service.ts | 19 ++- frontend/src/types/polar.ts | 6 +- frontend/src/utils/crypto.utils.ts | 51 ++++++ frontend/src/utils/polar.utils.ts | 157 ++++++++++++------- 8 files changed, 229 insertions(+), 156 deletions(-) diff --git a/frontend/src/app/api/products/route.ts b/frontend/src/app/api/products/route.ts index 68fb4cbb..7d201072 100644 --- a/frontend/src/app/api/products/route.ts +++ b/frontend/src/app/api/products/route.ts @@ -65,7 +65,7 @@ export async function POST(req: NextRequest) { } } -export async function PUT(req: NextRequest) { +export async function PATCH(req: NextRequest) { try { const { polarApiKey } = await getCurrentUser(req); const url = new URL(req.url); @@ -102,14 +102,17 @@ export async function DELETE(req: NextRequest) { ); } - await polarAPI.deleteProduct(polarApiKey, productId); + const product = await polarAPI.updateProduct(polarApiKey, productId, { + is_archived: true, + }); return NextResponse.json({ success: true, - message: 'Product deleted successfully', + message: 'Product archived successfully', + product, }); } catch (error: any) { return NextResponse.json( - { success: false, message: error.message || 'Failed to delete product' }, + { success: false, message: error.message || 'Failed to archive product' }, { status: 500 } ); } diff --git a/frontend/src/app/home/page.tsx b/frontend/src/app/home/page.tsx index a62e9738..895ca623 100644 --- a/frontend/src/app/home/page.tsx +++ b/frontend/src/app/home/page.tsx @@ -7,11 +7,7 @@ import HomeStats from '@/components/home/HomeStats'; import SalesDrawer from '@/components/SalesDrawer'; import PaymentScreen from '@/components/PaymentScreen'; import PaymentSuccessScreen from '@/components/PaymentSuccessScreen'; -import { - getActiveProducts, - salesAPI, - categoriesAPI, -} from '@/services/api.service'; +import { getActiveProducts, salesAPI } from '@/services/api.service'; import type { Product } from '@/types/index'; import { useAuth } from '@/context/auth-store'; @@ -34,9 +30,6 @@ const HomePage: React.FC = ({ const [products, setProducts] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const [categories, setCategories] = useState<{ id: string; name: string }[]>( - [] - ); // Shopping cart interface CartItem { @@ -51,41 +44,54 @@ const HomePage: React.FC = ({ // Use local search term (takes priority over external) const activeSearchTerm = localSearchTerm || externalSearchTerm; + const mapPolarProductToProduct = (p: any): Product => { + const prices = Array.isArray(p.prices) ? p.prices : []; + let price = 0; + if (prices.length > 0) { + const pr = prices[0] || {}; + const amt = pr.price_amount ?? pr.priceAmount; + const amtType = pr.amount_type ?? pr.amountType; + if (typeof amt === 'number') { + price = amtType === 'free' ? 0 : amt / 100; + } + } + return { + id: String(p.id), + name: p.name || 'Unnamed Product', + description: p.description || '', + price, + stock: 1, + min_stock: 0, + category_ids: [], + images: [], + status: 'active' as any, + weight: 0, + sku: String(p.id), + creator_id: '', + unit: 'Per item' as any, + product_type: 'Physical Product' as any, + localization: '', + created_at: p.created_at || p.createdAt || new Date().toISOString(), + last_updated: + p.modified_at || + p.modifiedAt || + p.updatedAt || + new Date().toISOString(), + } as Product; + }; + // Fetch products from backend useEffect(() => { const fetchProducts = async () => { - if (!isAuthenticated) { - setLoading(false); - return; - } - try { setLoading(true); const response = await getActiveProducts(); if (response && response.success && Array.isArray(response.products)) { - const availableProducts = response.products.map((product: any) => { - return { - id: product.id, - name: product.name || 'Unnamed Product', - description: product.description || '', - price: product.prices?.[0]?.price_amount - ? product.prices[0].price_amount / 100 - : 0, - stock: 1, - min_stock: 0, - category_ids: [], - images: [], - status: 'active' as any, - weight: 0, - sku: product.id, - creator_id: '', - unit: 'Per item' as any, - product_type: 'Physical Product' as any, - localization: '', - created_at: product.created_at || new Date().toISOString(), - last_updated: product.modified_at || new Date().toISOString(), - } as Product; - }); + const rows: any[] = response.products; + const filtered = rows.filter( + (p: any) => !(p.is_archived ?? p.is_archived) + ); + const availableProducts = filtered.map(mapPolarProductToProduct); setProducts(availableProducts); } else { setProducts([]); @@ -99,34 +105,11 @@ const HomePage: React.FC = ({ }; fetchProducts(); - }, [isAuthenticated]); - - // Fetch categories - useEffect(() => { - const loadCategories = async () => { - try { - const res = await categoriesAPI.list(); - if ((res as any).success) setCategories((res as any).categories); - } catch {} - }; - loadCategories(); }, []); - // Map category ids to names - const categoryIdMap: Record = useMemo( - () => Object.fromEntries(categories.map((c) => [c.id, c.name])), - [categories] - ); - const enrichedProducts = useMemo(() => { - return products.map((p: any) => { - const ids = Array.isArray(p.category_ids) ? p.category_ids : []; - const names = ids.map((id: string) => categoryIdMap[id]).filter(Boolean); - return { ...p, category_names: names } as Product & { - category_names?: string[]; - }; - }); - }, [products, categoryIdMap]); + return products; + }, [products]); // Filter products based on search term const filteredProducts = useMemo(() => { @@ -280,29 +263,11 @@ const HomePage: React.FC = ({ const response = await getActiveProducts(); if (response && response.success && Array.isArray(response.products)) { - const availableProducts = response.products.map((product: any) => { - return { - id: product.id, - name: product.name || 'Unnamed Product', - description: product.description || '', - price: product.prices?.[0]?.price_amount - ? product.prices[0].price_amount / 100 - : 0, - stock: 1, - min_stock: 0, - category_ids: [], - images: [], - status: 'active' as any, - weight: 0, - sku: product.id, - creator_id: '', - unit: 'Per item' as any, - product_type: 'Physical Product' as any, - localization: '', - created_at: product.created_at || new Date().toISOString(), - last_updated: product.modified_at || new Date().toISOString(), - } as Product; - }); + const rows: any[] = response.products; + const filtered = rows.filter( + (p: any) => !(p.is_archived ?? p.is_archived) + ); + const availableProducts = filtered.map(mapPolarProductToProduct); setProducts(availableProducts); } else { setProducts([]); diff --git a/frontend/src/components/ProductCard.tsx b/frontend/src/components/ProductCard.tsx index 602afbdf..1a2ed894 100644 --- a/frontend/src/components/ProductCard.tsx +++ b/frontend/src/components/ProductCard.tsx @@ -27,6 +27,7 @@ const ProductCard: React.FC = ({ product, onClick }) => { }); }; const images = getImageUrls(); + const fallbackImg = '/images/placeholder-product.png'; const [index, setIndex] = useState(0); const intervalRef = useRef(null); const unitLabel = (product as any).unit_name ?? 'per unit'; @@ -89,14 +90,13 @@ const ProductCard: React.FC = ({ product, onClick }) => { {images.map((src, i) => (
{product.name} { - (e.target as HTMLImageElement).style.visibility = - 'hidden'; + (e.target as HTMLImageElement).src = fallbackImg; }} />
diff --git a/frontend/src/components/home/ProductGrid.tsx b/frontend/src/components/home/ProductGrid.tsx index 027951c7..35cfe0e7 100644 --- a/frontend/src/components/home/ProductGrid.tsx +++ b/frontend/src/components/home/ProductGrid.tsx @@ -32,7 +32,7 @@ const ProductGrid: React.FC = ({ products, onProductClick }) => { } return ( -
+
{products.map((product) => ( { const token = getAuthToken(); - if (token) cfg.headers.Authorization = `Bearer ${token}`; + if (token) { + cfg.headers.Authorization = `Bearer ${token}`; + } else if (typeof window !== 'undefined') { + const userStr = localStorage.getItem('user'); + if (userStr) { + try { + const user = JSON.parse(userStr); + if (user && user.access_token) { + cfg.headers.Authorization = `Bearer ${user.access_token}`; + } + } catch {} + } + } return cfg; }); @@ -147,7 +159,10 @@ export const productsAPI = { getById: (productId: string): Promise => apiRequest(`/products/?id=${productId}`), update: (productId: string, updates: any): Promise => - apiRequest(`/products/?id=${productId}`, { method: 'PUT', data: updates }), + apiRequest(`/products/?id=${productId}`, { + method: 'PATCH', + data: updates, + }), delete: (productId: string): Promise => apiRequest(`/products/?id=${productId}`, { method: 'DELETE' }), list: (organizationId?: string): Promise => { diff --git a/frontend/src/types/polar.ts b/frontend/src/types/polar.ts index 4e639a0e..078edf05 100644 --- a/frontend/src/types/polar.ts +++ b/frontend/src/types/polar.ts @@ -14,7 +14,7 @@ export interface PolarProduct { id: string; name: string; description?: string | null; - isArchived?: boolean; + is_archived?: boolean; isRecurring?: boolean; organizationId: string; prices?: PolarProductPrice[]; @@ -26,7 +26,7 @@ export interface PolarProduct { export interface PolarProductsListParams { organizationId?: string; - isArchived?: boolean; + is_archived?: boolean; isRecurring?: boolean; query?: string; page?: number; @@ -53,5 +53,5 @@ export interface PolarProductUpdateBody { description?: string | null; prices?: PolarProductPrice[]; benefits?: string[]; - isArchived?: boolean; + is_archived?: boolean; } diff --git a/frontend/src/utils/crypto.utils.ts b/frontend/src/utils/crypto.utils.ts index d9942bcd..8f803e7f 100644 --- a/frontend/src/utils/crypto.utils.ts +++ b/frontend/src/utils/crypto.utils.ts @@ -29,6 +29,57 @@ export function decryptData(encryptedData: string): string { } } +export function decryptAny(encrypted: string): string { + if (!encrypted) return ''; + if (encrypted.startsWith('polar_oat_')) return encrypted; + try { + const out = decryptData(encrypted); + if (out && out.startsWith('polar_oat_')) return out; + } catch {} + try { + const parts = encrypted.split(':'); + if (parts.length === 2) { + const [ivB64, dataB64] = parts; + const iv = Buffer.from(ivB64, 'base64'); + const buf = Buffer.from(dataB64, 'base64'); + const decipher = crypto.createDecipheriv( + ALGORITHM, + Buffer.from(MASTER_KEY, 'hex'), + iv + ); + let dec = decipher.update(buf, undefined, 'utf8'); + dec += decipher.final('utf8'); + if (dec && dec.startsWith('polar_oat_')) return dec; + } + } catch {} + try { + const json = JSON.parse(encrypted); + const ivStr = json.iv || json.nonce; + const dataStr = json.data || json.ciphertext; + if (ivStr && dataStr) { + const iv = /^[A-Fa-f0-9]+$/.test(ivStr) + ? Buffer.from(ivStr, 'hex') + : Buffer.from(ivStr, 'base64'); + const buf = /^[A-Fa-f0-9]+$/.test(dataStr) + ? Buffer.from(dataStr, 'hex') + : Buffer.from(dataStr, 'base64'); + const decipher = crypto.createDecipheriv( + ALGORITHM, + Buffer.from(MASTER_KEY, 'hex'), + iv + ); + let dec = decipher.update(buf, undefined, 'utf8'); + dec += decipher.final('utf8'); + if (dec && dec.startsWith('polar_oat_')) return dec; + } + } catch {} + try { + const b = Buffer.from(encrypted, 'base64').toString('utf8'); + if (b && b.startsWith('polar_oat_')) return b; + } catch {} + return encrypted; +} + export function encryptData(data: string): string { try { const iv = crypto.randomBytes(16); diff --git a/frontend/src/utils/polar.utils.ts b/frontend/src/utils/polar.utils.ts index 6d09fd9b..4fa5cd06 100644 --- a/frontend/src/utils/polar.utils.ts +++ b/frontend/src/utils/polar.utils.ts @@ -1,12 +1,13 @@ -import { Polar } from '@polar-sh/sdk'; -import { decryptData } from './crypto.utils'; +import { decryptAny } from './crypto.utils'; + +const BASE_URL = 'https://api.polar.sh/v1'; export const decryptApiKey = (encryptedKey: string): string => { if (!encryptedKey || encryptedKey.trim() === '') { return process.env.POLAR_ACCESS_TOKEN || ''; } - const decrypted = decryptData(encryptedKey); + const decrypted = decryptAny(encryptedKey); if (!decrypted || decrypted === encryptedKey) { return process.env.POLAR_ACCESS_TOKEN || ''; } @@ -16,53 +17,84 @@ export const decryptApiKey = (encryptedKey: string): string => { export const polarAPI = { async listProducts(apiKey: string, organizationId?: string): Promise { - const client = new Polar({ - accessToken: decryptApiKey(apiKey), - }); - - const listArgs: any = {}; + const token = decryptApiKey(apiKey); const uuidRegex = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$/; + const params = new URLSearchParams(); if (organizationId && uuidRegex.test(organizationId)) { - listArgs.organizationId = organizationId; + params.set('organization_id', organizationId); } - - const result = await client.products.list(listArgs); - - const products: any[] = []; - for await (const page of result) { - const items = (page as any)?.items ?? page; - if (Array.isArray(items)) { - products.push(...items); - } + const url = `${BASE_URL}/products${ + params.toString() ? `?${params.toString()}` : '' + }`; + const res = await fetch(url, { + method: 'GET', + headers: { + Authorization: `Bearer ${token}`, + Accept: 'application/json', + }, + }); + if (!res.ok) { + const text = await res.text(); + throw new Error(text || 'Failed to list products'); } - return products; + const data: any = await res.json(); + const items = Array.isArray(data) + ? data + : Array.isArray(data.items) + ? data.items + : []; + console.log('POLAR listProducts', { + hasOrg: !!organizationId && uuidRegex.test(organizationId), + count: items.length, + sample: items[0]?.id || null, + }); + return items; }, async getProduct(apiKey: string, productId: string): Promise { - const client = new Polar({ - accessToken: decryptApiKey(apiKey), - }); - - const result = await client.products.get({ - id: productId, + const token = decryptApiKey(apiKey); + const res = await fetch(`${BASE_URL}/products/${productId}`, { + method: 'GET', + headers: { + Authorization: `Bearer ${token}`, + Accept: 'application/json', + }, }); - return result; + if (!res.ok) { + const text = await res.text(); + throw new Error(text || 'Failed to get product'); + } + return res.json(); }, async createProduct(apiKey: string, productData: any): Promise { - const client = new Polar({ - accessToken: decryptApiKey(apiKey), - }); - - const result = await client.products.create({ + const token = decryptApiKey(apiKey); + const body = { name: productData.name, description: productData.description, - organizationId: productData.organization_id, - recurringInterval: productData.recurringInterval || 'month', - prices: productData.prices || [{ amountType: 'free' }], + organization_id: + productData.organization_id || productData.organizationId, + recurring_interval: + productData.recurring_interval || + productData.recurringInterval || + 'month', + prices: productData.prices || [{ amount_type: 'free' }], + } as any; + const res = await fetch(`${BASE_URL}/products`, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), }); - return result; + if (!res.ok) { + const text = await res.text(); + throw new Error(text || 'Failed to create product'); + } + return res.json(); }, async updateProduct( @@ -70,36 +102,43 @@ export const polarAPI = { productId: string, productData: any ): Promise { - const client = new Polar({ - accessToken: decryptApiKey(apiKey), - }); - - const result = await client.products.update({ - id: productId, - productUpdate: { - name: productData.name, - description: productData.description, - prices: productData.prices, - isArchived: productData.isArchived, + const token = decryptApiKey(apiKey); + const body: any = { + name: productData.name, + description: productData.description, + prices: productData.prices, + is_archived: productData.is_archived, + }; + const res = await fetch(`${BASE_URL}/products/${productId}`, { + method: 'PATCH', + headers: { + Authorization: `Bearer ${token}`, + Accept: 'application/json', + 'Content-Type': 'application/json', }, + body: JSON.stringify(body), }); - return result; + if (!res.ok) { + const text = await res.text(); + throw new Error(text || 'Failed to update product'); + } + return res.json(); }, async deleteProduct(apiKey: string, productId: string): Promise { - const client = new Polar({ - accessToken: decryptApiKey(apiKey), + const token = decryptApiKey(apiKey); + const res = await fetch(`${BASE_URL}/products/${productId}`, { + method: 'PATCH', + headers: { + Authorization: `Bearer ${token}`, + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ is_archived: true }), }); - - try { - await client.products.update({ - id: productId, - productUpdate: { - isArchived: true, - }, - }); - } catch (error) { - throw new Error('Failed to archive product'); + if (!res.ok) { + const text = await res.text(); + throw new Error(text || 'Failed to archive product'); } }, }; From 923821fbded65bf07039c3b5cf5298200e08e73f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?essp=C3=ADndola?= Date: Wed, 17 Sep 2025 17:56:58 -0300 Subject: [PATCH 17/35] Enhance Polar API key handling; ensure proper error messaging for missing or invalid keys, and streamline decryption logic --- frontend/src/app/api/products/route.ts | 6 ++- frontend/src/utils/crypto.utils.ts | 62 +++++--------------------- frontend/src/utils/polar.utils.ts | 27 ++++++----- 3 files changed, 27 insertions(+), 68 deletions(-) diff --git a/frontend/src/app/api/products/route.ts b/frontend/src/app/api/products/route.ts index 7d201072..8c3eb906 100644 --- a/frontend/src/app/api/products/route.ts +++ b/frontend/src/app/api/products/route.ts @@ -15,8 +15,10 @@ async function getCurrentUser(req: NextRequest) { const user = await authService.verifyToken(token); const profile = await authService.getProfileUser(String(user.id)); - const polarApiKey = - profile.user?.polar_api_key || process.env.POLAR_ACCESS_TOKEN || ''; + const polarApiKey = profile.user?.polar_api_key || ''; + if (!polarApiKey) { + throw new Error('Missing Polar API key for user'); + } return { userId: user.id, diff --git a/frontend/src/utils/crypto.utils.ts b/frontend/src/utils/crypto.utils.ts index 8f803e7f..877781d8 100644 --- a/frontend/src/utils/crypto.utils.ts +++ b/frontend/src/utils/crypto.utils.ts @@ -1,7 +1,14 @@ import crypto from 'crypto'; const ALGORITHM = 'aes-256-cbc'; -const MASTER_KEY = process.env.MASTER_KEY || 'f8d3a9c7b6e4f1d2a5c8e0b9d7f6a3c2'; + +function getKey(): Buffer { + const mk = process.env.MASTER_KEY || ''; + if (!mk) throw new Error('Missing MASTER_KEY'); + const key = Buffer.from(mk, 'utf8'); + if (key.length !== 32) throw new Error('Invalid MASTER_KEY length'); + return key; +} export function decryptData(encryptedData: string): string { try { @@ -14,11 +21,7 @@ export function decryptData(encryptedData: string): string { const iv = Buffer.from(ivHex, 'hex'); const encrypted = Buffer.from(encryptedHex, 'hex'); - const decipher = crypto.createDecipheriv( - ALGORITHM, - Buffer.from(MASTER_KEY, 'hex'), - iv - ); + const decipher = crypto.createDecipheriv(ALGORITHM, getKey(), iv); let decrypted = decipher.update(encrypted, undefined, 'utf8'); decrypted += decipher.final('utf8'); @@ -36,58 +39,13 @@ export function decryptAny(encrypted: string): string { const out = decryptData(encrypted); if (out && out.startsWith('polar_oat_')) return out; } catch {} - try { - const parts = encrypted.split(':'); - if (parts.length === 2) { - const [ivB64, dataB64] = parts; - const iv = Buffer.from(ivB64, 'base64'); - const buf = Buffer.from(dataB64, 'base64'); - const decipher = crypto.createDecipheriv( - ALGORITHM, - Buffer.from(MASTER_KEY, 'hex'), - iv - ); - let dec = decipher.update(buf, undefined, 'utf8'); - dec += decipher.final('utf8'); - if (dec && dec.startsWith('polar_oat_')) return dec; - } - } catch {} - try { - const json = JSON.parse(encrypted); - const ivStr = json.iv || json.nonce; - const dataStr = json.data || json.ciphertext; - if (ivStr && dataStr) { - const iv = /^[A-Fa-f0-9]+$/.test(ivStr) - ? Buffer.from(ivStr, 'hex') - : Buffer.from(ivStr, 'base64'); - const buf = /^[A-Fa-f0-9]+$/.test(dataStr) - ? Buffer.from(dataStr, 'hex') - : Buffer.from(dataStr, 'base64'); - const decipher = crypto.createDecipheriv( - ALGORITHM, - Buffer.from(MASTER_KEY, 'hex'), - iv - ); - let dec = decipher.update(buf, undefined, 'utf8'); - dec += decipher.final('utf8'); - if (dec && dec.startsWith('polar_oat_')) return dec; - } - } catch {} - try { - const b = Buffer.from(encrypted, 'base64').toString('utf8'); - if (b && b.startsWith('polar_oat_')) return b; - } catch {} return encrypted; } export function encryptData(data: string): string { try { const iv = crypto.randomBytes(16); - const cipher = crypto.createCipheriv( - ALGORITHM, - Buffer.from(MASTER_KEY, 'hex'), - iv - ); + const cipher = crypto.createCipheriv(ALGORITHM, getKey(), iv); let encrypted = cipher.update(data, 'utf8', 'hex'); encrypted += cipher.final('hex'); diff --git a/frontend/src/utils/polar.utils.ts b/frontend/src/utils/polar.utils.ts index 4fa5cd06..28661945 100644 --- a/frontend/src/utils/polar.utils.ts +++ b/frontend/src/utils/polar.utils.ts @@ -3,26 +3,25 @@ import { decryptAny } from './crypto.utils'; const BASE_URL = 'https://api.polar.sh/v1'; export const decryptApiKey = (encryptedKey: string): string => { - if (!encryptedKey || encryptedKey.trim() === '') { - return process.env.POLAR_ACCESS_TOKEN || ''; - } - + if (!encryptedKey || encryptedKey.trim() === '') + throw new Error('Missing Polar API key'); + if (encryptedKey.includes('polar_oat_')) return encryptedKey; const decrypted = decryptAny(encryptedKey); - if (!decrypted || decrypted === encryptedKey) { - return process.env.POLAR_ACCESS_TOKEN || ''; - } - - return decrypted; + if ( + decrypted && + typeof decrypted === 'string' && + decrypted.includes('polar_oat_') + ) + return decrypted; + throw new Error('Invalid Polar API key'); }; export const polarAPI = { async listProducts(apiKey: string, organizationId?: string): Promise { const token = decryptApiKey(apiKey); - const uuidRegex = - /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$/; const params = new URLSearchParams(); - if (organizationId && uuidRegex.test(organizationId)) { - params.set('organization_id', organizationId); + if (organizationId && organizationId.trim() !== '') { + params.set('organization_id', organizationId.trim()); } const url = `${BASE_URL}/products${ params.toString() ? `?${params.toString()}` : '' @@ -45,7 +44,7 @@ export const polarAPI = { ? data.items : []; console.log('POLAR listProducts', { - hasOrg: !!organizationId && uuidRegex.test(organizationId), + hasOrg: !!organizationId, count: items.length, sample: items[0]?.id || null, }); From e98136a7b5a243840ef29cca4d814c4ae3d7fa9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?essp=C3=ADndola?= Date: Wed, 17 Sep 2025 18:31:03 -0300 Subject: [PATCH 18/35] Refactor Polar API key handling; simplify encryption logic and improve error handling for missing keys --- frontend/backend/auth/repository.ts | 14 ++------ frontend/src/utils/crypto.ts | 10 +----- frontend/src/utils/crypto.utils.ts | 53 ----------------------------- frontend/src/utils/polar.utils.ts | 17 ++------- 4 files changed, 7 insertions(+), 87 deletions(-) diff --git a/frontend/backend/auth/repository.ts b/frontend/backend/auth/repository.ts index 9dc07f22..913ee900 100644 --- a/frontend/backend/auth/repository.ts +++ b/frontend/backend/auth/repository.ts @@ -46,9 +46,7 @@ export class UserRepository { phone: payload.phone || null, role: payload.role || 'user', profile_image: payload.profile_image || null, - polar_api_key: payload.polar_api_key - ? encryptString(String(payload.polar_api_key)) - : null, + polar_api_key: payload.polar_api_key || null, created_at: now, last_updated: now, }; @@ -69,9 +67,7 @@ export class UserRepository { const now = getCurrentTimeWithTimezone('UTC'); updates.last_updated = now; if (typeof updates.polar_api_key !== 'undefined') { - updates.polar_api_key = updates.polar_api_key - ? encryptString(String(updates.polar_api_key)) - : null; + updates.polar_api_key = updates.polar_api_key || null; } const { data, error } = await supabase .from(this.table) @@ -92,11 +88,7 @@ export class UserRepository { .limit(1) .single(); if (!data || !data.polar_api_key) return null; - try { - return decryptString(data.polar_api_key as string); - } catch { - return null; - } + return data.polar_api_key as string; } async deleteUser(user_id: string): Promise { diff --git a/frontend/src/utils/crypto.ts b/frontend/src/utils/crypto.ts index c460c6ae..33adcc24 100644 --- a/frontend/src/utils/crypto.ts +++ b/frontend/src/utils/crypto.ts @@ -8,15 +8,7 @@ function getKey(): Buffer { } export function encryptString(plain: string): string { - const key = getKey(); - const iv = crypto.randomBytes(12); - const cipher = crypto.createCipheriv(ALGORITHM, key, iv); - const encrypted = Buffer.concat([ - cipher.update(plain, 'utf8'), - cipher.final(), - ]); - const tag = cipher.getAuthTag(); - return Buffer.concat([iv, tag, encrypted]).toString('base64'); + return plain; } export function decryptString(payload: string): string { diff --git a/frontend/src/utils/crypto.utils.ts b/frontend/src/utils/crypto.utils.ts index 877781d8..94cf86a6 100644 --- a/frontend/src/utils/crypto.utils.ts +++ b/frontend/src/utils/crypto.utils.ts @@ -1,57 +1,4 @@ -import crypto from 'crypto'; - -const ALGORITHM = 'aes-256-cbc'; - -function getKey(): Buffer { - const mk = process.env.MASTER_KEY || ''; - if (!mk) throw new Error('Missing MASTER_KEY'); - const key = Buffer.from(mk, 'utf8'); - if (key.length !== 32) throw new Error('Invalid MASTER_KEY length'); - return key; -} - -export function decryptData(encryptedData: string): string { - try { - const parts = encryptedData.split(':'); - if (parts.length !== 2) { - return encryptedData; - } - - const [ivHex, encryptedHex] = parts; - const iv = Buffer.from(ivHex, 'hex'); - const encrypted = Buffer.from(encryptedHex, 'hex'); - - const decipher = crypto.createDecipheriv(ALGORITHM, getKey(), iv); - let decrypted = decipher.update(encrypted, undefined, 'utf8'); - decrypted += decipher.final('utf8'); - - return decrypted; - } catch (error) { - console.error('Error decrypting data:', error); - return encryptedData; - } -} - export function decryptAny(encrypted: string): string { if (!encrypted) return ''; - if (encrypted.startsWith('polar_oat_')) return encrypted; - try { - const out = decryptData(encrypted); - if (out && out.startsWith('polar_oat_')) return out; - } catch {} return encrypted; } - -export function encryptData(data: string): string { - try { - const iv = crypto.randomBytes(16); - const cipher = crypto.createCipheriv(ALGORITHM, getKey(), iv); - let encrypted = cipher.update(data, 'utf8', 'hex'); - encrypted += cipher.final('hex'); - - return `${iv.toString('hex')}:${encrypted}`; - } catch (error) { - console.error('Error encrypting data:', error); - return data; - } -} diff --git a/frontend/src/utils/polar.utils.ts b/frontend/src/utils/polar.utils.ts index 28661945..0f470eda 100644 --- a/frontend/src/utils/polar.utils.ts +++ b/frontend/src/utils/polar.utils.ts @@ -1,19 +1,8 @@ -import { decryptAny } from './crypto.utils'; - const BASE_URL = 'https://api.polar.sh/v1'; -export const decryptApiKey = (encryptedKey: string): string => { - if (!encryptedKey || encryptedKey.trim() === '') - throw new Error('Missing Polar API key'); - if (encryptedKey.includes('polar_oat_')) return encryptedKey; - const decrypted = decryptAny(encryptedKey); - if ( - decrypted && - typeof decrypted === 'string' && - decrypted.includes('polar_oat_') - ) - return decrypted; - throw new Error('Invalid Polar API key'); +export const decryptApiKey = (rawKey: string): string => { + if (!rawKey || rawKey.trim() === '') throw new Error('Missing Polar API key'); + return rawKey; }; export const polarAPI = { From b6d0232f161f10065df596c39b1862c2edce415b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?essp=C3=ADndola?= Date: Thu, 18 Sep 2025 11:10:32 -0300 Subject: [PATCH 19/35] Refactor authentication and cookie handling; streamline token management and remove unnecessary parameters for improved clarity --- frontend/backend/OCR/.gitkeep | 0 frontend/backend/auth/service.ts | 20 +++++++++----- frontend/backend/back/.gitkeep | 0 frontend/src/context/auth-store.tsx | 33 ++++++++++-------------- frontend/src/services/cookies.service.ts | 5 +--- 5 files changed, 28 insertions(+), 30 deletions(-) delete mode 100644 frontend/backend/OCR/.gitkeep delete mode 100644 frontend/backend/back/.gitkeep diff --git a/frontend/backend/OCR/.gitkeep b/frontend/backend/OCR/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/frontend/backend/auth/service.ts b/frontend/backend/auth/service.ts index 82b9d3ed..4ccbd97a 100644 --- a/frontend/backend/auth/service.ts +++ b/frontend/backend/auth/service.ts @@ -7,7 +7,7 @@ import { uploadProfileImage } from '@/utils/cloudinary'; const SECRET_KEY = process.env.SECRET_KEY; const ALGORITHM = 'HS256'; const ACCESS_TOKEN_EXPIRE_MINUTES = Number( - process.env.ACCESS_TOKEN_EXPIRE_MINUTES || 60 + process.env.ACCESS_TOKEN_EXPIRE_MINUTES || 60 * 24 * 7 ); export class AuthService { @@ -33,7 +33,8 @@ export class AuthService { async login( email: string, - password: string + password: string, + expiresMinutes?: number ): Promise<{ user: UserItem; token: string }> { if (!email || !password) throw new Error('Email and password are required'); let user = await this.repo.findByEmail(email); @@ -45,7 +46,10 @@ export class AuthService { throw new Error('Invalid credentials'); const userData: UserItem = { ...user }; delete (userData as any).password; - const token = this.createAccessToken({ user_id: user.id }); + const token = this.createAccessToken( + { user_id: user.id }, + expiresMinutes + ); return { user: userData, token }; } catch (e) { throw new Error('Invalid credentials'); @@ -92,7 +96,10 @@ export class AuthService { if (!user) throw new Error('Authentication error'); const userData: UserItem = { ...user }; delete (userData as any).password; - const token = this.createAccessToken({ user_id: user.id }); + const token = this.createAccessToken( + { user_id: user.id }, + expiresMinutes + ); return { user: userData, token }; } catch (e) { throw new Error('Invalid credentials'); @@ -103,7 +110,8 @@ export class AuthService { full_name: string, email: string, password: string, - phone?: string + phone?: string, + expiresMinutes?: number ): Promise<{ user: UserItem; token: string }> { if (!email || !password || !full_name) throw new Error('Email, password and fullname are required'); @@ -116,7 +124,7 @@ export class AuthService { password: hashed, phone, }); - return this.login(email, password); + return this.login(email, password, expiresMinutes); } logout(token: string) { diff --git a/frontend/backend/back/.gitkeep b/frontend/backend/back/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/frontend/src/context/auth-store.tsx b/frontend/src/context/auth-store.tsx index 773e30e7..3ecc4cab 100644 --- a/frontend/src/context/auth-store.tsx +++ b/frontend/src/context/auth-store.tsx @@ -20,16 +20,13 @@ interface AuthState { loading: boolean; error: string | null; initialized: boolean; - login: (email: string, password: string, remember?: boolean) => Promise; - register: ( - data: { - full_name: string; - email: string; - password: string; - phone?: string; - }, - remember?: boolean - ) => Promise; + login: (email: string, password: string) => Promise; + register: (data: { + full_name: string; + email: string; + password: string; + phone?: string; + }) => Promise; logout: () => Promise; loadFromCookies: () => Promise; setUser: (user: User | null) => void; @@ -79,16 +76,14 @@ export const useAuthStore = create((set, get) => { } } }, - login: async (email, password, remember = false) => { + login: async (email, password) => { set({ loading: true, error: null }); try { const res = await authAPI.login({ email, password }); if (!res.token) throw new Error('Token no recibido'); - setAuthToken(res.token, remember); + setAuthToken(res.token); if (res.user) { - setCookie(USER_COOKIE, JSON.stringify(res.user), { - expires: remember ? 30 : 7, - }); + setCookie(USER_COOKIE, JSON.stringify(res.user), { expires: 7 }); } set({ token: res.token, user: res.user || null, loading: false }); } catch (e: any) { @@ -99,16 +94,14 @@ export const useAuthStore = create((set, get) => { throw e; } }, - register: async (data, remember = false) => { + register: async (data) => { set({ loading: true, error: null }); try { const res = await authAPI.register(data); if (!res.token) throw new Error('Token no recibido'); - setAuthToken(res.token, remember); + setAuthToken(res.token); if (res.user) { - setCookie(USER_COOKIE, JSON.stringify(res.user), { - expires: remember ? 30 : 7, - }); + setCookie(USER_COOKIE, JSON.stringify(res.user), { expires: 7 }); } set({ token: res.token, user: res.user || null, loading: false }); } catch (e: any) { diff --git a/frontend/src/services/cookies.service.ts b/frontend/src/services/cookies.service.ts index f0bcf9cb..48c261a7 100644 --- a/frontend/src/services/cookies.service.ts +++ b/frontend/src/services/cookies.service.ts @@ -13,11 +13,8 @@ const defaultOptions = (): CookieOptions => { }; }; -export function setAuthToken(token: string, remember = false) { +export function setAuthToken(token: string) { const opts = defaultOptions(); - if (remember) { - opts.expires = 30; - } Cookies.set(TOKEN_KEY, token, opts as Cookies.CookieAttributes); } From 26443a96561db403ef2f18029581eb2f68cf00dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?essp=C3=ADndola?= Date: Thu, 18 Sep 2025 11:22:42 -0300 Subject: [PATCH 20/35] Refactor ProductCard and SalesDrawer components; enhance product handling for Polar products, improve image and product info retrieval, and update key mapping for cart items --- frontend/src/components/ProductCard.tsx | 215 +++++++++++++++++------- frontend/src/components/SalesDrawer.tsx | 4 +- frontend/src/services/polar.service.ts | 138 +++++++++++++++ frontend/src/types/polar.ts | 129 ++++++++++---- 4 files changed, 394 insertions(+), 92 deletions(-) create mode 100644 frontend/src/services/polar.service.ts diff --git a/frontend/src/components/ProductCard.tsx b/frontend/src/components/ProductCard.tsx index 1a2ed894..79067d02 100644 --- a/frontend/src/components/ProductCard.tsx +++ b/frontend/src/components/ProductCard.tsx @@ -1,36 +1,93 @@ import React, { useState, useRef, useEffect } from 'react'; -import { Package } from 'lucide-react'; +import { Package, Archive, Repeat } from 'lucide-react'; import { Product } from '@/types/index'; +import { PolarProduct } from '@/types/polar'; interface ProductCardProps { - product: Product; - onClick: (product: Product) => void; + product: Product | PolarProduct; + onClick: (product: Product | PolarProduct) => void; } const ProductCard: React.FC = ({ product, onClick }) => { + const isPolarProduct = (p: any): p is PolarProduct => { + return 'medias' in p && 'organization_id' in p; + }; + const getImageUrls = () => { - if ( - !product.images || - !Array.isArray(product.images) || - product.images.length === 0 - ) - return []; - const apiBase = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4444'; - return product.images - .filter((v) => typeof v === 'string' && v.trim() !== '') - .map((raw) => { - if (/^(https?:\/\/|data:|blob:)/i.test(raw)) return raw; - if (/^\/\//.test(raw)) return `https:${raw}`; - return `${apiBase.replace(/\/$/, '')}${ - raw.startsWith('/') ? '' : '/' - }${raw}`; - }); + if (isPolarProduct(product)) { + return ( + product.medias + ?.filter( + (media) => media.mime_type.startsWith('image/') && media.public_url + ) + .map((media) => media.public_url) || [] + ); + } else { + if ( + !product.images || + !Array.isArray(product.images) || + product.images.length === 0 + ) + return []; + const apiBase = + process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4444'; + return product.images + .filter((v) => typeof v === 'string' && v.trim() !== '') + .map((raw) => { + if (/^(https?:\/\/|data:|blob:)/i.test(raw)) return raw; + if (/^\/\//.test(raw)) return `https:${raw}`; + return `${apiBase.replace(/\/$/, '')}${ + raw.startsWith('/') ? '' : '/' + }${raw}`; + }); + } + }; + + const getProductInfo = () => { + if (isPolarProduct(product)) { + const primaryPrice = product.prices?.[0]; + const formattedPrice = primaryPrice + ? `${primaryPrice.price_amount / 100} ${primaryPrice.price_currency}` + : 'Free'; + + return { + name: product.name, + description: product.description, + price: formattedPrice, + unit: primaryPrice?.recurring_interval + ? `per ${primaryPrice.recurring_interval}` + : 'one-time', + stock: null, + sku: product.id.slice(-8), + categoryNames: [], + isArchived: product.is_archived, + isRecurring: product.is_recurring, + recurringInterval: product.recurring_interval, + }; + } else { + const unitLabel = (product as any).unit_name ?? 'per unit'; + return { + name: product.name, + description: product.description, + price: `$${product.price.toFixed(2)}`, + unit: unitLabel, + stock: product.stock, + sku: product.sku, + categoryNames: + (product as any).category_names || product.category_ids || [], + isArchived: false, + isRecurring: false, + recurringInterval: null, + }; + } }; + const images = getImageUrls(); const fallbackImg = '/images/placeholder-product.png'; const [index, setIndex] = useState(0); const intervalRef = useRef(null); - const unitLabel = (product as any).unit_name ?? 'per unit'; + const productInfo = getProductInfo(); + const startSlide = () => { if (images.length <= 1) return; if (intervalRef.current) return; @@ -38,6 +95,7 @@ const ProductCard: React.FC = ({ product, onClick }) => { setIndex((prev) => (prev + 1) % images.length); }, 2000); }; + const stopSlide = () => { if (intervalRef.current) { clearInterval(intervalRef.current); @@ -45,12 +103,14 @@ const ProductCard: React.FC = ({ product, onClick }) => { } setIndex(0); }; + useEffect( () => () => { if (intervalRef.current) clearInterval(intervalRef.current); }, [] ); + return (
onClick(product)} @@ -58,28 +118,43 @@ const ProductCard: React.FC = ({ product, onClick }) => { onMouseLeave={stopSlide} className='relative overflow-hidden transition-all duration-300 ease-in-out transform bg-white border rounded-lg cursor-pointer group border-gray-300 hover:scale-105 hover:shadow-lg hover:border-gray-300 animate-fade-in' > -
- 10 - ? 'text-success-800' - : product.stock > 0 - ? 'text-warning-800' - : 'text-error-800' - }`} - > -
10 - ? 'bg-[#10B981]' - : product.stock > 0 - ? 'bg-[#f0ad4e]' - : 'bg-[#d9534f]' +
+ {productInfo.isArchived && ( + + + Archived + + )} + {productInfo.isRecurring && ( + + + {productInfo.recurringInterval} + + )} + {productInfo.stock !== null && ( + 10 + ? 'text-success-800' + : productInfo.stock > 0 + ? 'text-warning-800' + : 'text-error-800' }`} - >
- {product.stock} in stock - + > +
10 + ? 'bg-[#10B981]' + : productInfo.stock > 0 + ? 'bg-[#f0ad4e]' + : 'bg-[#d9534f]' + }`} + >
+ {productInfo.stock} in stock + + )}
+
{images.length > 0 ? (
@@ -91,7 +166,7 @@ const ProductCard: React.FC = ({ product, onClick }) => {
{product.name} = ({ product, onClick }) => {
))}
+ {images.length > 1 && ( +
+ {images.map((_, i) => ( +
+ ))} +
+ )}
) : (
@@ -112,45 +199,51 @@ const ProductCard: React.FC = ({ product, onClick }) => { )}
+
- {Array.isArray((product as any).category_names) && - (product as any).category_names.length > 0 ? ( - - {(product as any).category_names.join(', ')} - - ) : product.category_ids && product.category_ids.length > 0 ? ( + {Array.isArray(productInfo.categoryNames) && + productInfo.categoryNames.length > 0 ? ( - {product.category_ids.join(', ')} + {productInfo.categoryNames.join(', ')} ) : ( - - )} - {product.sku && ( - - {product.sku} + + Product )}
+

- {product.name} + {productInfo.name}

- {product.description ? ( + + {productInfo.description && (

- {product.description} + {productInfo.description}

- ) : null} + )} +
- - ${product.price.toFixed(2)} + + {productInfo.price} - - {unitLabel} + + {productInfo.unit}
+ + {images.length > 0 && ( +
+ + {images.length} image{images.length !== 1 ? 's' : ''} + +
+ )}
+
); diff --git a/frontend/src/components/SalesDrawer.tsx b/frontend/src/components/SalesDrawer.tsx index e881ba1d..67ba8b41 100644 --- a/frontend/src/components/SalesDrawer.tsx +++ b/frontend/src/components/SalesDrawer.tsx @@ -68,9 +68,9 @@ const SalesDrawer: React.FC = ({
{cartItems.length > 0 ? (
- {cartItems.map((item) => ( + {cartItems.map((item, index) => (
diff --git a/frontend/src/services/polar.service.ts b/frontend/src/services/polar.service.ts new file mode 100644 index 00000000..e0f01d55 --- /dev/null +++ b/frontend/src/services/polar.service.ts @@ -0,0 +1,138 @@ +import axios, { AxiosInstance } from 'axios'; +import { + PolarProduct, + PolarProductsListResponse, + PolarProductsListParams, + PolarProductCreateBody, + PolarProductUpdateBody, + PolarFileUploadRequest, + PolarFileUploadResponse, + PolarFilesListResponse, +} from '@/types/polar'; + +class PolarService { + private api: AxiosInstance; + + constructor() { + const baseURL = 'https://api.polar.sh/v1'; + const accessToken = process.env.POLAR_ACCESS_TOKEN; + + if (!accessToken) { + throw new Error('POLAR_ACCESS_TOKEN is required'); + } + + this.api = axios.create({ + baseURL, + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + }); + } + + async getProducts( + params?: PolarProductsListParams + ): Promise { + const response = await this.api.get('/products/', { params }); + return response.data; + } + + async getProduct(id: string): Promise { + const response = await this.api.get(`/products/${id}`); + return response.data; + } + + async createProduct(data: PolarProductCreateBody): Promise { + const response = await this.api.post('/products/', data); + return response.data; + } + + async updateProduct( + id: string, + data: PolarProductUpdateBody + ): Promise { + const response = await this.api.patch(`/products/${id}`, data); + return response.data; + } + + async deleteProduct(id: string): Promise { + const response = await this.api.patch(`/products/${id}`, { + is_archived: true, + }); + return response.data; + } + + async uploadFile( + file: File, + organizationId: string + ): Promise { + const fileData: PolarFileUploadRequest = { + name: file.name, + mime_type: file.type, + size: file.size, + organization_id: organizationId, + }; + + const createResponse = await this.api.post('/files/', fileData); + const fileInfo: PolarFileUploadResponse = createResponse.data; + + if (fileInfo.upload_url) { + await axios.put(fileInfo.upload_url, file, { + headers: { + 'Content-Type': file.type, + }, + }); + + await this.api.post(`/files/${fileInfo.id}/uploaded`); + } + + return fileInfo; + } + + async getFiles(organizationId?: string): Promise { + const params = organizationId ? { organization_id: organizationId } : {}; + const response = await this.api.get('/files/', { params }); + return response.data; + } + + async deleteFile(id: string): Promise { + await this.api.delete(`/files/${id}`); + } + + async addImageToProduct( + productId: string, + fileId: string + ): Promise { + const product = await this.getProduct(productId); + const mediaIds = product.medias?.map((m) => m.id) || []; + + if (!mediaIds.includes(fileId)) { + mediaIds.push(fileId); + } + + return this.updateProduct(productId, { + metadata: { + ...product.metadata, + media_ids: mediaIds, + }, + }); + } + + async removeImageFromProduct( + productId: string, + fileId: string + ): Promise { + const product = await this.getProduct(productId); + const mediaIds = + product.medias?.map((m) => m.id).filter((id) => id !== fileId) || []; + + return this.updateProduct(productId, { + metadata: { + ...product.metadata, + media_ids: mediaIds, + }, + }); + } +} + +export const polarService = new PolarService(); diff --git a/frontend/src/types/polar.ts b/frontend/src/types/polar.ts index 078edf05..839f1cd6 100644 --- a/frontend/src/types/polar.ts +++ b/frontend/src/types/polar.ts @@ -1,57 +1,128 @@ export interface PolarProductPrice { - amountType: 'fixed' | 'free' | 'pay_what_you_want'; - priceAmount?: number; - priceCurrency?: string; + created_at: string; + modified_at: string; + id: string; + amount_type: string; + is_archived: boolean; + product_id: string; + type: string; + recurring_interval?: 'day' | 'week' | 'month' | 'year'; + price_currency: string; + price_amount: number; + legacy: boolean; +} + +export interface PolarBenefit { + id: string; + created_at: string; + modified_at: string; + type: 'custom' | 'discord' | 'github_repository' | 'ads'; + description: string; + selectable: boolean; + deletable: boolean; + organization_id: string; + metadata: Record; + properties: { + note?: string; + }; } -export interface PolarProductMedia { +export interface PolarMedia { id: string; - url: string; - type: 'image' | 'video'; + organization_id: string; + name: string; + path: string; + mime_type: string; + size: number; + public_url: string; + storage_version?: string; + checksum_etag?: string; + checksum_sha256_base64?: string; + checksum_sha256_hex?: string; + last_modified_at?: string; + version?: string; + service?: string; + is_uploaded?: boolean; + created_at?: string; + size_readable?: string; } export interface PolarProduct { + created_at: string; + modified_at: string; id: string; name: string; - description?: string | null; - is_archived?: boolean; - isRecurring?: boolean; - organizationId: string; - prices?: PolarProductPrice[]; - benefits?: string[]; - media?: PolarProductMedia[]; - createdAt: string; - modifiedAt?: string; + description: string; + recurring_interval?: 'day' | 'week' | 'month' | 'year'; + is_recurring: boolean; + is_archived: boolean; + organization_id: string; + metadata: Record; + prices: PolarProductPrice[]; + benefits: PolarBenefit[]; + medias: PolarMedia[]; +} + +export interface PolarPagination { + total_count: number; + max_page: number; +} + +export interface PolarProductsListResponse { + items: PolarProduct[]; + pagination: PolarPagination; } export interface PolarProductsListParams { - organizationId?: string; + organization_id?: string; is_archived?: boolean; - isRecurring?: boolean; + is_recurring?: boolean; query?: string; page?: number; limit?: number; } -export interface PolarProductsListResponsePage { - items: PolarProduct[]; - nextPage?: number | null; - prevPage?: number | null; -} - export interface PolarProductCreateBody { name: string; - description?: string | null; - organizationId: string; - recurringInterval?: 'month' | 'year' | null; - prices?: PolarProductPrice[]; + description?: string; + organization_id: string; + recurring_interval?: 'day' | 'week' | 'month' | 'year'; + is_recurring?: boolean; + prices?: Partial[]; benefits?: string[]; + metadata?: Record; } export interface PolarProductUpdateBody { name?: string; - description?: string | null; - prices?: PolarProductPrice[]; + description?: string; + prices?: Partial[]; benefits?: string[]; is_archived?: boolean; + metadata?: Record; +} + +export interface PolarFileUploadRequest { + name: string; + mime_type: string; + size: number; + organization_id?: string; +} + +export interface PolarFileUploadResponse { + id: string; + organization_id: string; + name: string; + path: string; + mime_type: string; + size: number; + upload_url?: string; + public_url?: string; + created_at: string; + is_uploaded: boolean; +} + +export interface PolarFilesListResponse { + items: PolarMedia[]; + pagination: PolarPagination; } From 963db3cfee11c10b4e81c2cab8f26c5d65950725 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?essp=C3=ADndola?= Date: Thu, 18 Sep 2025 11:44:46 -0300 Subject: [PATCH 21/35] Enhance product data retrieval; filter and map image URLs in HomePage component, and include additional parameters for product API requests. --- frontend/src/app/home/page.tsx | 14 ++++++++++++-- frontend/src/utils/polar.utils.ts | 32 ++++++++++++++++++------------- 2 files changed, 31 insertions(+), 15 deletions(-) diff --git a/frontend/src/app/home/page.tsx b/frontend/src/app/home/page.tsx index 895ca623..bf60c9a1 100644 --- a/frontend/src/app/home/page.tsx +++ b/frontend/src/app/home/page.tsx @@ -55,6 +55,17 @@ const HomePage: React.FC = ({ price = amtType === 'free' ? 0 : amt / 100; } } + const imageUrls = Array.isArray(p.medias) + ? p.medias + .filter( + (m: any) => + m && + typeof m.public_url === 'string' && + m.mime_type && + m.mime_type.startsWith('image/') + ) + .map((m: any) => m.public_url) + : []; return { id: String(p.id), name: p.name || 'Unnamed Product', @@ -63,7 +74,7 @@ const HomePage: React.FC = ({ stock: 1, min_stock: 0, category_ids: [], - images: [], + images: imageUrls, status: 'active' as any, weight: 0, sku: String(p.id), @@ -261,7 +272,6 @@ const HomePage: React.FC = ({ setLoading(true); setError(null); const response = await getActiveProducts(); - if (response && response.success && Array.isArray(response.products)) { const rows: any[] = response.products; const filtered = rows.filter( diff --git a/frontend/src/utils/polar.utils.ts b/frontend/src/utils/polar.utils.ts index 0f470eda..8f611c4b 100644 --- a/frontend/src/utils/polar.utils.ts +++ b/frontend/src/utils/polar.utils.ts @@ -9,9 +9,13 @@ export const polarAPI = { async listProducts(apiKey: string, organizationId?: string): Promise { const token = decryptApiKey(apiKey); const params = new URLSearchParams(); + + params.set('expand', 'medias,prices,benefits'); + if (organizationId && organizationId.trim() !== '') { params.set('organization_id', organizationId.trim()); } + const url = `${BASE_URL}/products${ params.toString() ? `?${params.toString()}` : '' }`; @@ -32,28 +36,30 @@ export const polarAPI = { : Array.isArray(data.items) ? data.items : []; - console.log('POLAR listProducts', { - hasOrg: !!organizationId, - count: items.length, - sample: items[0]?.id || null, - }); return items; }, async getProduct(apiKey: string, productId: string): Promise { const token = decryptApiKey(apiKey); - const res = await fetch(`${BASE_URL}/products/${productId}`, { - method: 'GET', - headers: { - Authorization: `Bearer ${token}`, - Accept: 'application/json', - }, - }); + const params = new URLSearchParams(); + params.set('include', 'medias,prices,benefits'); + + const res = await fetch( + `${BASE_URL}/products/${productId}?${params.toString()}`, + { + method: 'GET', + headers: { + Authorization: `Bearer ${token}`, + Accept: 'application/json', + }, + } + ); if (!res.ok) { const text = await res.text(); throw new Error(text || 'Failed to get product'); } - return res.json(); + const product = await res.json(); + return product; }, async createProduct(apiKey: string, productData: any): Promise { From fe6904de5412c845719e00173949620550b33a7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?essp=C3=ADndola?= Date: Thu, 18 Sep 2025 12:26:34 -0300 Subject: [PATCH 22/35] Add polar_organization_id to UserItem and related components; update user profile handling and UI --- frontend/backend/auth/models.ts | 1 + frontend/backend/auth/repository.ts | 4 ++++ frontend/src/app/profile/page.tsx | 10 +++++++++- frontend/src/components/SideMenu.tsx | 7 +++++++ frontend/src/types/index.ts | 1 + 5 files changed, 22 insertions(+), 1 deletion(-) diff --git a/frontend/backend/auth/models.ts b/frontend/backend/auth/models.ts index bbd2026c..1e79ff0e 100644 --- a/frontend/backend/auth/models.ts +++ b/frontend/backend/auth/models.ts @@ -9,6 +9,7 @@ export interface UserItem { phone?: string | null; profile_image?: string | null; polar_api_key?: string | null; + polar_organization_id?: string | null; } export interface LoginRequest { diff --git a/frontend/backend/auth/repository.ts b/frontend/backend/auth/repository.ts index 913ee900..a0f567ff 100644 --- a/frontend/backend/auth/repository.ts +++ b/frontend/backend/auth/repository.ts @@ -47,6 +47,7 @@ export class UserRepository { role: payload.role || 'user', profile_image: payload.profile_image || null, polar_api_key: payload.polar_api_key || null, + polar_organization_id: payload.polar_organization_id || null, created_at: now, last_updated: now, }; @@ -69,6 +70,9 @@ export class UserRepository { if (typeof updates.polar_api_key !== 'undefined') { updates.polar_api_key = updates.polar_api_key || null; } + if (typeof updates.polar_organization_id !== 'undefined') { + updates.polar_organization_id = updates.polar_organization_id || null; + } const { data, error } = await supabase .from(this.table) .update(updates) diff --git a/frontend/src/app/profile/page.tsx b/frontend/src/app/profile/page.tsx index dfa333b5..59b856fc 100644 --- a/frontend/src/app/profile/page.tsx +++ b/frontend/src/app/profile/page.tsx @@ -75,7 +75,11 @@ const ProfilePage: React.FC = () => { const oldValue = profileData[field]; if (newValue !== oldValue) { - if (field === 'phone' || field === 'polar_api_key') { + if ( + field === 'phone' || + field === 'polar_api_key' || + field === 'polar_organization_id' + ) { payload[field] = newValue || null; } else if (newValue) { payload[field] = newValue; @@ -291,6 +295,10 @@ const ProfilePage: React.FC = () => { 'Polar API Key', 'password' )} + {renderField( + 'polar_organization_id', + 'Polar Organization ID' + )}
{hasChanges && ( diff --git a/frontend/src/components/SideMenu.tsx b/frontend/src/components/SideMenu.tsx index 058e651f..bae37e65 100644 --- a/frontend/src/components/SideMenu.tsx +++ b/frontend/src/components/SideMenu.tsx @@ -134,6 +134,13 @@ const SideMenu: React.FC = () => { description: 'View inventory', alwaysVisible: true, }, + { + name: 'Profile', + icon: User, + path: '/profile', + description: 'Manage account', + alwaysVisible: true, + }, { name: 'Smart Inventory', icon: Brain, diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 47d23214..3ee3cbe2 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -21,6 +21,7 @@ export interface User { full_name: string; phone?: string; polar_api_key?: string; + polar_organization_id?: string; role: RoleUser; profile_image?: string; created_at?: string; From 2f8644e488e53c22ea74089e45778ed0aa0dc6a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?essp=C3=ADndola?= Date: Thu, 18 Sep 2025 13:29:27 -0300 Subject: [PATCH 23/35] Add polar organization ID handling in product creation and inventory management; enhance product upload logic and validation --- frontend/src/app/api/products/route.ts | 78 +++++++- frontend/src/app/inventory/page.tsx | 70 ++++++- frontend/src/app/new-product/page.tsx | 254 ++++++++++++++++++------- frontend/src/services/api.service.ts | 38 +++- frontend/src/utils/polar.utils.ts | 72 ++++++- 5 files changed, 417 insertions(+), 95 deletions(-) diff --git a/frontend/src/app/api/products/route.ts b/frontend/src/app/api/products/route.ts index 8c3eb906..3eb6dd31 100644 --- a/frontend/src/app/api/products/route.ts +++ b/frontend/src/app/api/products/route.ts @@ -24,6 +24,7 @@ async function getCurrentUser(req: NextRequest) { userId: user.id, userEmail: user.email, polarApiKey: polarApiKey, + polarOrganizationId: profile.user?.polar_organization_id || '', }; } catch (error) { throw new Error('Invalid authentication'); @@ -54,11 +55,80 @@ export async function GET(req: NextRequest) { export async function POST(req: NextRequest) { try { - const { polarApiKey } = await getCurrentUser(req); - const body: any = await req.json(); + const { polarApiKey, polarOrganizationId } = await getCurrentUser(req); + const contentType = req.headers.get('content-type') || ''; - const product = await polarAPI.createProduct(polarApiKey, body); - return NextResponse.json({ success: true, product }); + if (contentType.includes('multipart/form-data')) { + const form = await req.formData(); + const name = String(form.get('name') || ''); + const description = String(form.get('description') || ''); + const organizationId = String(polarOrganizationId || ''); + const billing = String(form.get('billing_interval') || 'once'); + const priceStr = String(form.get('price') || '0'); + const stockStr = String(form.get('stock') || '0'); + const file = form.get('image') as File | null; + + if (!name) { + return NextResponse.json( + { success: false, message: 'name is required' }, + { status: 400 } + ); + } + + if (file && file.size > 0) { + try { + await polarAPI.uploadFile( + polarApiKey, + file, + organizationId, + 'product_media' + ); + } catch {} + } + + const priceAmount = Math.round(Number(priceStr) * 100); + const isOnce = billing === 'once'; + const prices = [ + { + amount_type: 'fixed', + price_currency: 'usd', + price_amount: priceAmount, + type: isOnce ? 'one_time' : 'recurring', + recurring_interval: isOnce ? undefined : billing, + legacy: false, + is_archived: false, + }, + ]; + + const product = await polarAPI.createProduct(polarApiKey, { + name, + description: description || undefined, + recurring_interval: isOnce ? null : billing, + prices, + metadata: { quantity: Number(stockStr || '0') }, + }); + return NextResponse.json({ success: true, product }); + } else { + const body: any = await req.json(); + if (!body.name) { + return NextResponse.json( + { success: false, message: 'name is required' }, + { status: 400 } + ); + } + const prices = Array.isArray(body.prices) ? body.prices : []; + const product = await polarAPI.createProduct(polarApiKey, { + name: body.name, + description: body.description, + recurring_interval: + typeof body.recurring_interval === 'string' + ? body.recurring_interval + : null, + prices, + metadata: body.metadata || {}, + }); + return NextResponse.json({ success: true, product }); + } } catch (error: any) { return NextResponse.json( { success: false, message: error.message || 'Failed to create product' }, diff --git a/frontend/src/app/inventory/page.tsx b/frontend/src/app/inventory/page.tsx index 60942a08..48488978 100644 --- a/frontend/src/app/inventory/page.tsx +++ b/frontend/src/app/inventory/page.tsx @@ -3,7 +3,11 @@ import React, { useState, useEffect } from 'react'; import { useRouter } from 'next/navigation'; import { Package } from 'lucide-react'; -import { productsAPI, categoriesAPI } from '@/services/api.service'; +import { + productsAPI, + categoriesAPI, + getActiveProducts, +} from '@/services/api.service'; import { Product } from '@/types/index'; import { useAuth } from '@/context/auth-store'; import InventoryHeader from '@/components/inventory/InventoryHeader'; @@ -27,6 +31,52 @@ const InventoryPage: React.FC = () => { const [error, setError] = useState(null); const [deleteConfirmId, setDeleteConfirmId] = useState(null); const [isDeleting, setIsDeleting] = useState(false); + const mapPolarProductToProduct = (p: any): Product => { + const prices = Array.isArray(p.prices) ? p.prices : []; + let price = 0; + if (prices.length > 0) { + const pr = prices[0] || {}; + const amt = pr.price_amount ?? pr.priceAmount; + const amtType = pr.amount_type ?? pr.amountType; + if (typeof amt === 'number') { + price = amtType === 'free' ? 0 : amt / 100; + } + } + const imageUrls = Array.isArray(p.medias) + ? p.medias + .filter( + (m: any) => + m && + typeof m.public_url === 'string' && + m.mime_type && + m.mime_type.startsWith('image/') + ) + .map((m: any) => m.public_url) + : []; + return { + id: String(p.id), + name: p.name || 'Unnamed Product', + description: p.description || '', + price, + stock: 1, + min_stock: 0, + category_ids: [], + images: imageUrls, + status: 'active' as any, + weight: 0, + sku: String(p.id), + creator_id: '', + unit: 'Per item' as any, + product_type: 'Physical Product' as any, + localization: '', + created_at: p.created_at || p.createdAt || new Date().toISOString(), + last_updated: + p.modified_at || + p.modifiedAt || + p.updatedAt || + new Date().toISOString(), + } as Product; + }; useEffect(() => { const fetchInventory = async () => { @@ -42,15 +92,21 @@ const InventoryPage: React.FC = () => { try { setLoading(true); - const response = await productsAPI.list(); - if (!response || !response.products) { + const response = await getActiveProducts(); + if (response && response.success && Array.isArray(response.products)) { + const rows: any[] = response.products; + const filtered = rows.filter( + (p: any) => !(p.is_archived ?? p.is_archived) + ); + const availableProducts = filtered.map(mapPolarProductToProduct); + setInventoryItems(availableProducts); + setError(null); + } else { + setInventoryItems([]); setError('Error loading inventory'); - return; } - - setInventoryItems(response.products); - setError(null); } catch (err) { + setInventoryItems([]); setError('Error loading inventory'); } finally { setLoading(false); diff --git a/frontend/src/app/new-product/page.tsx b/frontend/src/app/new-product/page.tsx index 33bd5efd..aee11109 100644 --- a/frontend/src/app/new-product/page.tsx +++ b/frontend/src/app/new-product/page.tsx @@ -4,12 +4,10 @@ import React, { useState, useEffect } from 'react'; import { useRouter } from 'next/navigation'; import Header from '@/components/new-product/Header'; import ImagesUploader from '@/components/new-product/ImagesUploader'; -import NewProductForm from '@/components/new-product/NewProductForm'; +import { productsAPI } from '@/services/api.service'; import { useAuth } from '@/context/auth-store'; -import { productsAPI, categoriesAPI } from '@/services/api.service'; import { Formik, Form } from 'formik'; import * as Yup from 'yup'; -import ProductInfoForm from '@/components/new-product/ProductInfoForm'; const ALLOWED_TYPES = ['image/png', 'image/jpeg', 'image/jpg', 'image/webp']; const MAX_FILES = 4; @@ -17,7 +15,7 @@ const MAX_SIZE = 5 * 1024 * 1024; const NewProductPage: React.FC = () => { const router = useRouter(); - const { isAuthenticated } = useAuth(); + const { isAuthenticated, user } = useAuth(); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); const [files, setFiles] = useState([]); @@ -45,17 +43,10 @@ const NewProductPage: React.FC = () => { }; }, []); - const unitOptions = [ - { label: 'Per item', value: 'Per item' }, - { label: 'Per kilogram', value: 'Per kilogram' }, - { label: 'Per liter', value: 'Per liter' }, - { label: 'Per meter', value: 'Per meter' }, - ]; - - const productTypeOptions = [ - { label: 'Physical Product', value: 'Physical Product' }, - { label: 'Service', value: 'Service' }, - { label: 'Digital', value: 'Digital' }, + const billingOptions = [ + { label: 'One-time', value: 'once' }, + { label: 'Monthly', value: 'month' }, + { label: 'Yearly', value: 'year' }, ]; const handleAddFiles = (f: FileList | null) => { @@ -83,47 +74,60 @@ const NewProductPage: React.FC = () => { } try { + if (!user?.polar_organization_id) { + setError('Polar organization ID is required in profile'); + setSaving(false); + return; + } + + const organizationId = user.polar_organization_id; + const prices = [ + { + amount_type: 'fixed', + price_currency: 'usd', + price_amount: Math.round(Number(values.price) * 100), + type: values.billingInterval === 'once' ? 'one_time' : 'recurring', + recurring_interval: + values.billingInterval === 'once' + ? undefined + : values.billingInterval, + legacy: false, + is_archived: false, + }, + ]; + + const metadata: any = { quantity: Number(values.stock || 0) }; + if (values.category && String(values.category).trim() !== '') + metadata.category = String(values.category).trim(); + if (values.subcategory && String(values.subcategory).trim() !== '') + metadata.subcategory = String(values.subcategory).trim(); + if (values.tags && String(values.tags).trim() !== '') { + const arr = String(values.tags) + .split(',') + .map((t) => t.trim()) + .filter((t) => t.length > 0); + if (arr.length > 0) metadata.tags = arr.join(','); + } + const payload: Record = { name: values.name, description: values.description && values.description.trim() !== '' ? values.description - : '', - price: Number(values.price), - stock: Number(values.inventoryQuantity), - unit: values.unit, - product_type: values.productType || 'Physical Product', - status: 'active', - sku: - values.sku && values.sku.trim() !== '' - ? values.sku - : 'SKU-' + Date.now(), - min_stock: values.lowStockAlert ? Number(values.lowStockAlert) : 0, - category_ids: Array.isArray(values.category_ids) - ? values.category_ids - : [], - weight: values.weight ? Number(values.weight) : undefined, - localization: - values.location && values.location.trim() !== '' - ? values.location : undefined, + organization_id: organizationId, + recurring_interval: + values.billingInterval === 'once' ? null : values.billingInterval, + prices, + metadata, }; - Object.keys(payload).forEach((k) => { - if (payload[k] === undefined) delete payload[k]; - }); - - const res = await productsAPI.create(payload as any); - const productObj = (res as any).product ? (res as any).product : res; - const newId = productObj?.id; - if (!newId) { - setError('No se pudo obtener el ID del producto'); - setSaving(false); - return; - } + const body: any = { ...payload }; if (files.length > 0) { - await productsAPI.addImages(newId, files); + body.images = files.slice(0, 1); } + + await productsAPI.create(body as any, organizationId); router.push('/inventory'); } catch (err: unknown) { setError(err instanceof Error ? err.message : 'Error creating product'); @@ -134,35 +138,30 @@ const NewProductPage: React.FC = () => { const validationSchema = Yup.object().shape({ name: Yup.string().required('Name is required'), - unit: Yup.string().required('Unit is required'), price: Yup.number() .typeError('Price must be a number') .positive('Price must be greater than 0') .required('Price is required'), - inventoryQuantity: Yup.number() - .typeError('Inventory must be a number') - .integer('Inventory must be an integer') - .min(0, 'Inventory must be 0 or more') - .required('Inventory is required'), - category_ids: Yup.array() - .of(Yup.string().uuid('Invalid id')) - .min(1, 'At least one category') - .required('At least one category'), + stock: Yup.number() + .typeError('Stock must be a number') + .integer('Stock must be an integer') + .min(0, 'Stock must be 0 or more') + .required('Stock is required'), + billingInterval: Yup.string() + .oneOf(['once', 'month', 'year']) + .required('Billing interval is required'), }); const initialValues = { - productType: 'Physical Product', name: '', description: '', - location: '', - unit: 'Per item', - weight: '', price: '', - inventoryQuantity: '', - lowStockAlert: '', - sku: '', - category_ids: [], - }; + stock: '', + billingInterval: 'once', + category: '', + subcategory: '', + tags: '', + } as any; return (
@@ -188,13 +187,126 @@ const NewProductPage: React.FC = () => { onAddFiles={handleAddFiles} onRemove={handleRemoveFile} /> - +
+
+
+ +