enh
This commit is contained in:
parent
1b042819af
commit
18f36107d8
33 changed files with 1793 additions and 1164 deletions
|
|
@ -1,7 +1,6 @@
|
|||
# Agent Instructions for Meat Farmer Monorepo
|
||||
|
||||
## Important instructions
|
||||
- Don't try to build the code or run or compile it. Just make changes and leave the rest for the user.
|
||||
- Don't run any drizzle migrations. User will handle it.
|
||||
|
||||
## Code Style Guidelines
|
||||
|
|
@ -48,6 +47,4 @@ react-native. They are available in the common-ui as MyText, MyTextInput, MyTouc
|
|||
- Database: Drizzle ORM with PostgreSQL
|
||||
|
||||
## Important Notes
|
||||
- **Do not run build, compile, or migration commands** - These should be handled manually by developers
|
||||
- Avoid running `npm run build`, `tsc`, `drizzle-kit generate`, or similar compilation/migration commands
|
||||
- Don't do anything with git. Don't do git add or git commit. That will be managed entirely by the user
|
||||
|
|
|
|||
|
|
@ -1,17 +1,8 @@
|
|||
import 'dotenv/config';
|
||||
import { Hono } from 'hono';
|
||||
import { cors } from 'hono/cors';
|
||||
import { logger } from 'hono/logger';
|
||||
import { serve } from '@hono/node-server';
|
||||
import { trpcServer } from '@hono/trpc-server';
|
||||
import { getStaffUserById, isUserSuspended } from '@/src/dbService';
|
||||
import mainRouter from '@/src/main-router';
|
||||
import initFunc from '@/src/lib/init';
|
||||
import { appRouter } from '@/src/trpc/router';
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { jwtVerify } from 'jose'
|
||||
import { encodedJwtSecret } from '@/src/lib/env-exporter';
|
||||
import signedUrlCache from '@/src/lib/signed-url-cache';
|
||||
import { createApp } from '@/src/app'
|
||||
// import signedUrlCache from '@/src/lib/signed-url-cache';
|
||||
import { seed } from '@/src/lib/seed';
|
||||
import '@/src/jobs/jobs-index';
|
||||
import { startAutomatedJobs } from '@/src/lib/automatedJobs';
|
||||
|
|
@ -20,120 +11,9 @@ seed()
|
|||
initFunc()
|
||||
startAutomatedJobs()
|
||||
|
||||
const app = new Hono();
|
||||
// signedUrlCache.loadFromDisk(); // Disabled for Workers compatibility
|
||||
|
||||
// CORS middleware
|
||||
app.use(cors({
|
||||
origin: 'http://localhost:5174',
|
||||
allowMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
||||
allowHeaders: ['Origin', 'X-Requested-With', 'Content-Type', 'Accept', 'Authorization'],
|
||||
credentials: true,
|
||||
}));
|
||||
|
||||
signedUrlCache.loadFromDisk();
|
||||
|
||||
// Logger middleware
|
||||
app.use(logger());
|
||||
|
||||
// tRPC middleware
|
||||
app.use('/api/trpc', trpcServer({
|
||||
router: appRouter,
|
||||
createContext: async ({ req }) => {
|
||||
let user = null;
|
||||
let staffUser = null;
|
||||
const authHeader = req.headers.get('authorization');
|
||||
|
||||
if (authHeader?.startsWith('Bearer ')) {
|
||||
const token = authHeader.substring(7);
|
||||
try {
|
||||
const { payload } = await jwtVerify(token, encodedJwtSecret);
|
||||
const decoded = payload as any;
|
||||
|
||||
// Check if this is a staff token (has staffId)
|
||||
if (decoded.staffId) {
|
||||
// This is a staff token, verify staff exists
|
||||
const staff = await getStaffUserById(decoded.staffId);
|
||||
|
||||
if (staff) {
|
||||
user = staffUser
|
||||
staffUser = {
|
||||
id: staff.id,
|
||||
name: staff.name,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
|
||||
// This is a regular user token
|
||||
user = decoded;
|
||||
|
||||
// Check if user is suspended
|
||||
const suspended = await isUserSuspended(user.userId);
|
||||
|
||||
if (suspended) {
|
||||
throw new TRPCError({
|
||||
code: 'FORBIDDEN',
|
||||
message: 'Account suspended',
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// Invalid token, both user and staffUser remain null
|
||||
}
|
||||
}
|
||||
return { req, user, staffUser };
|
||||
},
|
||||
onError({ error, path, type, ctx }) {
|
||||
console.error('🚨 tRPC Error :', {
|
||||
path,
|
||||
type,
|
||||
code: error.code,
|
||||
message: error.message,
|
||||
userId: ctx?.user?.userId,
|
||||
stack: error.stack,
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
// Mount main router
|
||||
app.route('/api', mainRouter);
|
||||
|
||||
// Global error handler
|
||||
app.onError((err, c) => {
|
||||
console.error(err);
|
||||
// Handle different error types
|
||||
let status = 500;
|
||||
let message = 'Internal Server Error';
|
||||
|
||||
if (err instanceof TRPCError) {
|
||||
// Map TRPC error codes to HTTP status codes
|
||||
const trpcStatusMap: Record<string, number> = {
|
||||
'BAD_REQUEST': 400,
|
||||
'UNAUTHORIZED': 401,
|
||||
'FORBIDDEN': 403,
|
||||
'NOT_FOUND': 404,
|
||||
'TIMEOUT': 408,
|
||||
'CONFLICT': 409,
|
||||
'PRECONDITION_FAILED': 412,
|
||||
'PAYLOAD_TOO_LARGE': 413,
|
||||
'METHOD_NOT_SUPPORTED': 405,
|
||||
'UNPROCESSABLE_CONTENT': 422,
|
||||
'TOO_MANY_REQUESTS': 429,
|
||||
'INTERNAL_SERVER_ERROR': 500,
|
||||
};
|
||||
status = trpcStatusMap[err.code] || 500;
|
||||
message = err.message;
|
||||
} else if ((err as any).statusCode) {
|
||||
status = (err as any).statusCode;
|
||||
message = err.message;
|
||||
} else if ((err as any).status) {
|
||||
status = (err as any).status;
|
||||
message = err.message;
|
||||
} else if (err.message) {
|
||||
message = err.message;
|
||||
}
|
||||
|
||||
return c.json({ message }, status as any);
|
||||
});
|
||||
const app = createApp()
|
||||
|
||||
serve({
|
||||
fetch: app.fetch,
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@
|
|||
"dev2": "tsx watch index.ts",
|
||||
"dev_node": "tsx watch index.ts",
|
||||
"dev": "bun --watch index.ts",
|
||||
"wrangler:dev": "wrangler dev worker.ts --config wrangler.toml",
|
||||
"wrangler:deploy": "wrangler deploy worker.ts --config wrangler.toml",
|
||||
"docker:build": "cd .. && docker buildx build --platform linux/amd64 -t mohdshafiuddin54/health_petal:latest --progress=plain -f backend/Dockerfile .",
|
||||
"docker:push": "docker push mohdshafiuddin54/health_petal:latest"
|
||||
},
|
||||
|
|
@ -36,11 +38,13 @@
|
|||
"zod": "^4.1.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "^4.20260304.0",
|
||||
"@types/node": "^24.5.2",
|
||||
"rimraf": "^6.1.2",
|
||||
"ts-node-dev": "^2.0.0",
|
||||
"tsc-alias": "^1.8.16",
|
||||
"tsx": "^4.20.5",
|
||||
"typescript": "^5.9.2"
|
||||
"typescript": "^5.9.2",
|
||||
"wrangler": "^3.114.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,236 +0,0 @@
|
|||
import { Context } from 'hono';
|
||||
import {
|
||||
checkProductExistsByName,
|
||||
checkUnitExists,
|
||||
createProduct as createProductRecord,
|
||||
createSpecialDealsForProduct,
|
||||
getProductImagesById,
|
||||
replaceProductTags,
|
||||
updateProduct as updateProductRecord,
|
||||
updateProductDeals,
|
||||
} from '@/src/dbService'
|
||||
import { ApiError } from "@/src/lib/api-error";
|
||||
import type { AdminSpecialDeal } from '@packages/shared'
|
||||
import { imageUploadS3, getOriginalUrlFromSignedUrl } from "@/src/lib/s3-client";
|
||||
import { deleteS3Image } from "@/src/lib/delete-image";
|
||||
import { scheduleStoreInitialization } from '@/src/stores/store-initializer';
|
||||
|
||||
|
||||
type CreateDeal = {
|
||||
quantity: number;
|
||||
price: number;
|
||||
validTill: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a new product
|
||||
*/
|
||||
export const createProduct = async (c: Context) => {
|
||||
const body = await c.req.parseBody({ all: true });
|
||||
const { name, shortDescription, longDescription, unitId, storeId, price, marketPrice, incrementStep, productQuantity, isSuspended, isFlashAvailable, flashPrice, deals, tagIds } = body;
|
||||
|
||||
// Validate required fields
|
||||
if (!name || !unitId || !storeId || !price) {
|
||||
throw new ApiError("Name, unitId, storeId, and price are required", 400);
|
||||
}
|
||||
|
||||
// Check for duplicate name
|
||||
const existingProduct = await checkProductExistsByName((name as string).trim())
|
||||
|
||||
if (existingProduct) {
|
||||
throw new ApiError("A product with this name already exists", 400);
|
||||
}
|
||||
|
||||
// Check if unit exists
|
||||
const unitExists = await checkUnitExists(parseInt(unitId as string))
|
||||
|
||||
if (!unitExists) {
|
||||
throw new ApiError("Invalid unit ID", 400);
|
||||
}
|
||||
|
||||
// Extract images from body
|
||||
const images = body.images;
|
||||
let uploadedImageUrls: string[] = [];
|
||||
|
||||
if (images) {
|
||||
const imageFiles = Array.isArray(images) ? images : [images];
|
||||
const imageUploadPromises = imageFiles.map((file, index) => {
|
||||
if (file instanceof File) {
|
||||
const key = `product-images/${Date.now()}-${index}`;
|
||||
return imageUploadS3(Buffer.from(file.stream() as any), file.type, key);
|
||||
}
|
||||
return null;
|
||||
}).filter(Boolean);
|
||||
|
||||
uploadedImageUrls = await Promise.all(imageUploadPromises as Promise<string>[]);
|
||||
}
|
||||
|
||||
// Create product
|
||||
const productData: any = {
|
||||
name: name as string,
|
||||
shortDescription: shortDescription as string | undefined,
|
||||
longDescription: longDescription as string | undefined,
|
||||
unitId: parseInt(unitId as string),
|
||||
storeId: parseInt(storeId as string),
|
||||
price: parseFloat(price as string),
|
||||
marketPrice: marketPrice ? parseFloat(marketPrice as string) : null,
|
||||
incrementStep: incrementStep ? parseInt(incrementStep as string) : 1,
|
||||
productQuantity: productQuantity ? parseInt(productQuantity as string) : 1,
|
||||
isSuspended: isSuspended === 'true',
|
||||
isFlashAvailable: isFlashAvailable === 'true',
|
||||
images: uploadedImageUrls,
|
||||
};
|
||||
|
||||
if (flashPrice) {
|
||||
productData.flashPrice = parseFloat(flashPrice as string);
|
||||
}
|
||||
|
||||
const newProduct = await createProductRecord(productData)
|
||||
|
||||
// Handle deals if provided
|
||||
let createdDeals: AdminSpecialDeal[] = []
|
||||
if (deals) {
|
||||
const parsedDeals = typeof deals === 'string' ? JSON.parse(deals) : deals;
|
||||
if (Array.isArray(parsedDeals)) {
|
||||
createdDeals = await createSpecialDealsForProduct(newProduct.id, parsedDeals)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle tag assignments if provided
|
||||
if (tagIds) {
|
||||
const parsedTagIds = typeof tagIds === 'string' ? JSON.parse(tagIds) : tagIds;
|
||||
if (Array.isArray(parsedTagIds)) {
|
||||
await replaceProductTags(newProduct.id, parsedTagIds)
|
||||
}
|
||||
}
|
||||
|
||||
// Reinitialize stores to reflect changes
|
||||
scheduleStoreInitialization()
|
||||
|
||||
// Send response first
|
||||
return c.json({
|
||||
product: newProduct,
|
||||
deals: createdDeals,
|
||||
message: "Product created successfully",
|
||||
}, 201);
|
||||
};
|
||||
|
||||
/**
|
||||
* Update a product
|
||||
*/
|
||||
export const updateProduct = async (c: Context) => {
|
||||
const id = c.req.param('id')
|
||||
const body = await c.req.parseBody({ all: true });
|
||||
const { name, shortDescription, longDescription, unitId, storeId, price, marketPrice, incrementStep, productQuantity, isSuspended, isFlashAvailable, flashPrice, deals:dealsRaw, imagesToDelete:imagesToDeleteRaw, tagIds } = body;
|
||||
|
||||
|
||||
const deals = dealsRaw ? (typeof dealsRaw === 'string' ? JSON.parse(dealsRaw) : dealsRaw) : null;
|
||||
const imagesToDelete = imagesToDeleteRaw ? (typeof imagesToDeleteRaw === 'string' ? JSON.parse(imagesToDeleteRaw) : imagesToDeleteRaw) : [];
|
||||
|
||||
if (!name || !unitId || !storeId || !price) {
|
||||
throw new ApiError("Name, unitId, storeId, and price are required", 400);
|
||||
}
|
||||
|
||||
// Check if unit exists
|
||||
const unitExists = await checkUnitExists(parseInt(unitId as string))
|
||||
|
||||
if (!unitExists) {
|
||||
throw new ApiError("Invalid unit ID", 400);
|
||||
}
|
||||
|
||||
// Get current product to handle image updates
|
||||
const currentImages = await getProductImagesById(parseInt(id as string))
|
||||
|
||||
if (!currentImages) {
|
||||
throw new ApiError("Product not found", 404);
|
||||
}
|
||||
|
||||
// Handle image deletions
|
||||
let updatedImages = currentImages || []
|
||||
if (imagesToDelete && imagesToDelete.length > 0) {
|
||||
// Convert signed URLs to original S3 URLs for comparison
|
||||
const originalUrlsToDelete = imagesToDelete
|
||||
.map((signedUrl: string) => getOriginalUrlFromSignedUrl(signedUrl))
|
||||
.filter(Boolean); // Remove nulls
|
||||
|
||||
// Find which stored images match the ones to delete
|
||||
const imagesToRemoveFromDb = updatedImages.filter(storedUrl =>
|
||||
originalUrlsToDelete.includes(storedUrl)
|
||||
);
|
||||
|
||||
// Delete the matching images from S3
|
||||
const deletePromises = imagesToRemoveFromDb.map(imageUrl => deleteS3Image(imageUrl));
|
||||
await Promise.all(deletePromises);
|
||||
|
||||
// Remove deleted images from current images array
|
||||
updatedImages = updatedImages.filter(img => !imagesToRemoveFromDb.includes(img));
|
||||
}
|
||||
|
||||
// Extract new images from body
|
||||
const images = body.images;
|
||||
let uploadedImageUrls: string[] = [];
|
||||
|
||||
if (images) {
|
||||
const imageFiles = Array.isArray(images) ? images : [images];
|
||||
const imageUploadPromises = imageFiles.map((file, index) => {
|
||||
if (file instanceof File) {
|
||||
const key = `product-images/${Date.now()}-${index}`;
|
||||
return imageUploadS3(Buffer.from(file.stream() as any), file.type, key);
|
||||
}
|
||||
return null;
|
||||
}).filter(Boolean);
|
||||
|
||||
uploadedImageUrls = await Promise.all(imageUploadPromises as Promise<string>[]);
|
||||
}
|
||||
|
||||
// Combine remaining current images with new uploaded images
|
||||
const finalImages = [...updatedImages, ...uploadedImageUrls];
|
||||
|
||||
const updateData: any = {
|
||||
name: name as string,
|
||||
shortDescription: shortDescription as string | undefined,
|
||||
longDescription: longDescription as string | undefined,
|
||||
unitId: parseInt(unitId as string),
|
||||
storeId: parseInt(storeId as string),
|
||||
price: parseFloat(price as string),
|
||||
marketPrice: marketPrice ? parseFloat(marketPrice as string) : null,
|
||||
incrementStep: incrementStep ? parseInt(incrementStep as string) : 1,
|
||||
productQuantity: productQuantity ? parseInt(productQuantity as string) : 1,
|
||||
isSuspended: isSuspended === 'true',
|
||||
images: finalImages.length > 0 ? finalImages : undefined,
|
||||
};
|
||||
|
||||
if (isFlashAvailable !== undefined) {
|
||||
updateData.isFlashAvailable = isFlashAvailable === 'true';
|
||||
}
|
||||
|
||||
if (flashPrice !== undefined) {
|
||||
updateData.flashPrice = flashPrice ? parseFloat(flashPrice as string) : null;
|
||||
}
|
||||
|
||||
const updatedProduct = await updateProductRecord(parseInt(id as string), updateData)
|
||||
|
||||
if (!updatedProduct) {
|
||||
throw new ApiError("Product not found", 404);
|
||||
}
|
||||
|
||||
// Handle deals if provided
|
||||
if (deals && Array.isArray(deals)) {
|
||||
await updateProductDeals(parseInt(id as string), deals)
|
||||
}
|
||||
|
||||
// Handle tag assignments if provided
|
||||
if (tagIds) {
|
||||
const parsedTagIds = typeof tagIds === 'string' ? [parseInt(tagIds)] : (Array.isArray(tagIds) ? tagIds.map((t: any) => parseInt(t)) : [parseInt(tagIds as any)])
|
||||
await replaceProductTags(parseInt(id as string), parsedTagIds)
|
||||
}
|
||||
|
||||
// Reinitialize stores to reflect changes
|
||||
scheduleStoreInitialization()
|
||||
|
||||
// Send response first
|
||||
return c.json({
|
||||
product: updatedProduct,
|
||||
message: "Product updated successfully",
|
||||
}, 200);
|
||||
};
|
||||
126
apps/backend/src/app.ts
Normal file
126
apps/backend/src/app.ts
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
import { Hono } from 'hono'
|
||||
import { cors } from 'hono/cors'
|
||||
import { logger } from 'hono/logger'
|
||||
import { trpcServer } from '@hono/trpc-server'
|
||||
import { getStaffUserById, isUserSuspended } from '@/src/dbService'
|
||||
import mainRouter from '@/src/main-router'
|
||||
import { appRouter } from '@/src/trpc/router'
|
||||
import { TRPCError } from '@trpc/server'
|
||||
import { jwtVerify } from 'jose'
|
||||
import { encodedJwtSecret } from '@/src/lib/env-exporter'
|
||||
|
||||
export const createApp = () => {
|
||||
const app = new Hono()
|
||||
|
||||
// CORS middleware
|
||||
app.use(cors({
|
||||
origin: 'http://localhost:5174',
|
||||
allowMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
||||
allowHeaders: ['Origin', 'X-Requested-With', 'Content-Type', 'Accept', 'Authorization'],
|
||||
credentials: true,
|
||||
}))
|
||||
|
||||
// Logger middleware
|
||||
app.use(logger())
|
||||
|
||||
// tRPC middleware
|
||||
app.use('/api/trpc', trpcServer({
|
||||
router: appRouter,
|
||||
createContext: async ({ req }) => {
|
||||
let user = null
|
||||
let staffUser = null
|
||||
const authHeader = req.headers.get('authorization')
|
||||
|
||||
if (authHeader?.startsWith('Bearer ')) {
|
||||
const token = authHeader.substring(7)
|
||||
try {
|
||||
const { payload } = await jwtVerify(token, encodedJwtSecret)
|
||||
const decoded = payload as any
|
||||
|
||||
// Check if this is a staff token (has staffId)
|
||||
if (decoded.staffId) {
|
||||
// This is a staff token, verify staff exists
|
||||
const staff = await getStaffUserById(decoded.staffId)
|
||||
|
||||
if (staff) {
|
||||
user = staffUser
|
||||
staffUser = {
|
||||
id: staff.id,
|
||||
name: staff.name,
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// This is a regular user token
|
||||
user = decoded
|
||||
|
||||
// Check if user is suspended
|
||||
const suspended = await isUserSuspended(user.userId)
|
||||
|
||||
if (suspended) {
|
||||
throw new TRPCError({
|
||||
code: 'FORBIDDEN',
|
||||
message: 'Account suspended',
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// Invalid token, both user and staffUser remain null
|
||||
}
|
||||
}
|
||||
return { req, user, staffUser }
|
||||
},
|
||||
onError({ error, path, type, ctx }) {
|
||||
console.error('🚨 tRPC Error :', {
|
||||
path,
|
||||
type,
|
||||
code: error.code,
|
||||
message: error.message,
|
||||
userId: ctx?.user?.userId,
|
||||
stack: error.stack,
|
||||
})
|
||||
},
|
||||
}))
|
||||
|
||||
// Mount main router
|
||||
app.route('/api', mainRouter)
|
||||
|
||||
// Global error handler
|
||||
app.onError((err, c) => {
|
||||
console.error(err)
|
||||
// Handle different error types
|
||||
let status = 500
|
||||
let message = 'Internal Server Error'
|
||||
|
||||
if (err instanceof TRPCError) {
|
||||
// Map TRPC error codes to HTTP status codes
|
||||
const trpcStatusMap: Record<string, number> = {
|
||||
BAD_REQUEST: 400,
|
||||
UNAUTHORIZED: 401,
|
||||
FORBIDDEN: 403,
|
||||
NOT_FOUND: 404,
|
||||
TIMEOUT: 408,
|
||||
CONFLICT: 409,
|
||||
PRECONDITION_FAILED: 412,
|
||||
PAYLOAD_TOO_LARGE: 413,
|
||||
METHOD_NOT_SUPPORTED: 405,
|
||||
UNPROCESSABLE_CONTENT: 422,
|
||||
TOO_MANY_REQUESTS: 429,
|
||||
INTERNAL_SERVER_ERROR: 500,
|
||||
}
|
||||
status = trpcStatusMap[err.code] || 500
|
||||
message = err.message
|
||||
} else if ((err as any).statusCode) {
|
||||
status = (err as any).statusCode
|
||||
message = err.message
|
||||
} else if ((err as any).status) {
|
||||
status = (err as any).status
|
||||
message = err.message
|
||||
} else if (err.message) {
|
||||
message = err.message
|
||||
}
|
||||
|
||||
return c.json({ message }, status as any)
|
||||
})
|
||||
|
||||
return app
|
||||
}
|
||||
|
|
@ -2,10 +2,13 @@
|
|||
// This file re-exports everything from postgresImporter to provide a clean abstraction layer
|
||||
|
||||
import type { AdminOrderDetails } from '@packages/shared'
|
||||
import { getOrderDetails } from '@/src/postgresImporter'
|
||||
// import { getOrderDetails } from '@/src/postgresImporter'
|
||||
import { getOrderDetails } from '@/src/sqliteImporter'
|
||||
|
||||
// Re-export everything from postgresImporter
|
||||
export * from '@/src/postgresImporter'
|
||||
// export * from '@/src/postgresImporter'
|
||||
|
||||
export * from '@/src/sqliteImporter'
|
||||
|
||||
// Re-export getOrderDetails with the correct signature
|
||||
export async function getOrderDetailsWrapper(orderId: number): Promise<AdminOrderDetails | null> {
|
||||
|
|
|
|||
|
|
@ -1,57 +1,116 @@
|
|||
|
||||
export const appUrl = process.env.APP_URL as string;
|
||||
// Old env loading (Node only)
|
||||
// export const appUrl = process.env.APP_URL as string;
|
||||
//
|
||||
// export const jwtSecret: string = process.env.JWT_SECRET as string
|
||||
//
|
||||
// export const defaultRoleName = 'gen_user';
|
||||
//
|
||||
// export const encodedJwtSecret = new TextEncoder().encode(jwtSecret)
|
||||
//
|
||||
// export const s3AccessKeyId = process.env.S3_ACCESS_KEY_ID as string
|
||||
//
|
||||
// export const s3SecretAccessKey = process.env.S3_SECRET_ACCESS_KEY as string
|
||||
//
|
||||
// export const s3BucketName = process.env.S3_BUCKET_NAME as string
|
||||
//
|
||||
// export const s3Region = process.env.S3_REGION as string
|
||||
//
|
||||
// export const assetsDomain = process.env.ASSETS_DOMAIN as string;
|
||||
//
|
||||
// export const apiCacheKey = process.env.API_CACHE_KEY as string;
|
||||
//
|
||||
// export const cloudflareApiToken = process.env.CLOUDFLARE_API_TOKEN as string;
|
||||
//
|
||||
// export const cloudflareZoneId = process.env.CLOUDFLARE_ZONE_ID as string;
|
||||
//
|
||||
// export const s3Url = process.env.S3_URL as string
|
||||
//
|
||||
// export const redisUrl = process.env.REDIS_URL as string
|
||||
//
|
||||
//
|
||||
// export const expoAccessToken = process.env.EXPO_ACCESS_TOKEN as string;
|
||||
//
|
||||
// export const phonePeBaseUrl = process.env.PHONE_PE_BASE_URL as string;
|
||||
//
|
||||
// export const phonePeClientId = process.env.PHONE_PE_CLIENT_ID as string;
|
||||
//
|
||||
// export const phonePeClientVersion = Number(process.env.PHONE_PE_CLIENT_VERSION as string);
|
||||
//
|
||||
// export const phonePeClientSecret = process.env.PHONE_PE_CLIENT_SECRET as string;
|
||||
//
|
||||
// export const phonePeMerchantId = process.env.PHONE_PE_MERCHANT_ID as string;
|
||||
//
|
||||
// export const razorpayId = process.env.RAZORPAY_KEY as string;
|
||||
//
|
||||
// export const razorpaySecret = process.env.RAZORPAY_SECRET as string;
|
||||
//
|
||||
// export const otpSenderAuthToken = process.env.OTP_SENDER_AUTH_TOKEN as string;
|
||||
//
|
||||
// export const minOrderValue = Number(process.env.MIN_ORDER_VALUE as string);
|
||||
//
|
||||
// export const deliveryCharge = Number(process.env.DELIVERY_CHARGE as string);
|
||||
//
|
||||
// export const telegramBotToken = process.env.TELEGRAM_BOT_TOKEN as string;
|
||||
//
|
||||
// export const telegramChatIds = (process.env.TELEGRAM_CHAT_IDS as string)?.split(',').map(id => id.trim()) || [];
|
||||
//
|
||||
// export const isDevMode = (process.env.ENV_MODE as string) === 'dev';
|
||||
|
||||
export const jwtSecret: string = process.env.JWT_SECRET as string
|
||||
const runtimeEnv = (globalThis as any).ENV || (globalThis as any).process?.env || {}
|
||||
|
||||
export const appUrl = runtimeEnv.APP_URL as string
|
||||
|
||||
export const jwtSecret: string = runtimeEnv.JWT_SECRET as string
|
||||
|
||||
export const defaultRoleName = 'gen_user';
|
||||
|
||||
export const encodedJwtSecret = new TextEncoder().encode(jwtSecret)
|
||||
|
||||
export const s3AccessKeyId = process.env.S3_ACCESS_KEY_ID as string
|
||||
export const s3AccessKeyId = runtimeEnv.S3_ACCESS_KEY_ID as string
|
||||
|
||||
export const s3SecretAccessKey = process.env.S3_SECRET_ACCESS_KEY as string
|
||||
export const s3SecretAccessKey = runtimeEnv.S3_SECRET_ACCESS_KEY as string
|
||||
|
||||
export const s3BucketName = process.env.S3_BUCKET_NAME as string
|
||||
export const s3BucketName = runtimeEnv.S3_BUCKET_NAME as string
|
||||
|
||||
export const s3Region = process.env.S3_REGION as string
|
||||
export const s3Region = runtimeEnv.S3_REGION as string
|
||||
|
||||
export const assetsDomain = process.env.ASSETS_DOMAIN as string;
|
||||
export const assetsDomain = runtimeEnv.ASSETS_DOMAIN as string
|
||||
|
||||
export const apiCacheKey = process.env.API_CACHE_KEY as string;
|
||||
export const apiCacheKey = runtimeEnv.API_CACHE_KEY as string
|
||||
|
||||
export const cloudflareApiToken = process.env.CLOUDFLARE_API_TOKEN as string;
|
||||
export const cloudflareApiToken = runtimeEnv.CLOUDFLARE_API_TOKEN as string
|
||||
|
||||
export const cloudflareZoneId = process.env.CLOUDFLARE_ZONE_ID as string;
|
||||
export const cloudflareZoneId = runtimeEnv.CLOUDFLARE_ZONE_ID as string
|
||||
|
||||
export const s3Url = process.env.S3_URL as string
|
||||
export const s3Url = runtimeEnv.S3_URL as string
|
||||
|
||||
export const redisUrl = process.env.REDIS_URL as string
|
||||
export const redisUrl = runtimeEnv.REDIS_URL as string
|
||||
|
||||
export const expoAccessToken = runtimeEnv.EXPO_ACCESS_TOKEN as string
|
||||
|
||||
export const expoAccessToken = process.env.EXPO_ACCESS_TOKEN as string;
|
||||
export const phonePeBaseUrl = runtimeEnv.PHONE_PE_BASE_URL as string
|
||||
|
||||
export const phonePeBaseUrl = process.env.PHONE_PE_BASE_URL as string;
|
||||
export const phonePeClientId = runtimeEnv.PHONE_PE_CLIENT_ID as string
|
||||
|
||||
export const phonePeClientId = process.env.PHONE_PE_CLIENT_ID as string;
|
||||
export const phonePeClientVersion = Number(runtimeEnv.PHONE_PE_CLIENT_VERSION as string)
|
||||
|
||||
export const phonePeClientVersion = Number(process.env.PHONE_PE_CLIENT_VERSION as string);
|
||||
export const phonePeClientSecret = runtimeEnv.PHONE_PE_CLIENT_SECRET as string
|
||||
|
||||
export const phonePeClientSecret = process.env.PHONE_PE_CLIENT_SECRET as string;
|
||||
export const phonePeMerchantId = runtimeEnv.PHONE_PE_MERCHANT_ID as string
|
||||
|
||||
export const phonePeMerchantId = process.env.PHONE_PE_MERCHANT_ID as string;
|
||||
export const razorpayId = runtimeEnv.RAZORPAY_KEY as string
|
||||
|
||||
export const razorpayId = process.env.RAZORPAY_KEY as string;
|
||||
export const razorpaySecret = runtimeEnv.RAZORPAY_SECRET as string
|
||||
|
||||
export const razorpaySecret = process.env.RAZORPAY_SECRET as string;
|
||||
export const otpSenderAuthToken = runtimeEnv.OTP_SENDER_AUTH_TOKEN as string
|
||||
|
||||
export const otpSenderAuthToken = process.env.OTP_SENDER_AUTH_TOKEN as string;
|
||||
export const minOrderValue = Number(runtimeEnv.MIN_ORDER_VALUE as string)
|
||||
|
||||
export const minOrderValue = Number(process.env.MIN_ORDER_VALUE as string);
|
||||
export const deliveryCharge = Number(runtimeEnv.DELIVERY_CHARGE as string)
|
||||
|
||||
export const deliveryCharge = Number(process.env.DELIVERY_CHARGE as string);
|
||||
export const telegramBotToken = runtimeEnv.TELEGRAM_BOT_TOKEN as string
|
||||
|
||||
export const telegramBotToken = process.env.TELEGRAM_BOT_TOKEN as string;
|
||||
export const telegramChatIds = (runtimeEnv.TELEGRAM_CHAT_IDS as string)?.split(',').map(id => id.trim()) || []
|
||||
|
||||
export const telegramChatIds = (process.env.TELEGRAM_CHAT_IDS as string)?.split(',').map(id => id.trim()) || [];
|
||||
|
||||
export const isDevMode = (process.env.ENV_MODE as string) === 'dev';
|
||||
export const isDevMode = (runtimeEnv.ENV_MODE as string) === 'dev'
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
// import { s3A, awsBucketName, awsRegion, awsSecretAccessKey } from "@/src/lib/env-exporter"
|
||||
import { DeleteObjectCommand, DeleteObjectsCommand, PutObjectCommand, S3Client, GetObjectCommand } from "@aws-sdk/client-s3"
|
||||
import { getSignedUrl } from "@aws-sdk/s3-request-presigner"
|
||||
import signedUrlCache from "@/src/lib/signed-url-cache"
|
||||
// import signedUrlCache from "@/src/lib/signed-url-cache" // Disabled for Workers compatibility
|
||||
import { claimUploadUrlStatus, createUploadUrlStatus } from '@/src/dbService'
|
||||
import { s3AccessKeyId, s3Region, s3Url, s3SecretAccessKey, s3BucketName, assetsDomain } from "@/src/lib/env-exporter"
|
||||
|
||||
|
|
@ -89,12 +89,11 @@ export async function generateSignedUrlFromS3Url(s3UrlRaw: string|null, expiresI
|
|||
const s3Url = s3UrlRaw
|
||||
|
||||
try {
|
||||
// Check if we have a cached signed URL
|
||||
const cachedUrl = signedUrlCache.get(s3Url);
|
||||
if (cachedUrl) {
|
||||
// Found in cache, return it
|
||||
return cachedUrl;
|
||||
}
|
||||
// Cache disabled for Workers compatibility
|
||||
// const cachedUrl = signedUrlCache.get(s3Url);
|
||||
// if (cachedUrl) {
|
||||
// return cachedUrl;
|
||||
// }
|
||||
|
||||
// Create the command to get the object
|
||||
const command = new GetObjectCommand({
|
||||
|
|
@ -105,8 +104,8 @@ export async function generateSignedUrlFromS3Url(s3UrlRaw: string|null, expiresI
|
|||
// Generate the signed URL
|
||||
const signedUrl = await getSignedUrl(s3Client, command, { expiresIn });
|
||||
|
||||
// Cache the signed URL with TTL matching the expiration time (convert seconds to milliseconds)
|
||||
signedUrlCache.set(s3Url, signedUrl, (expiresIn * 1000) - 60000); // Subtract 1 minute to ensure it doesn't expire before use
|
||||
// Cache disabled for Workers compatibility
|
||||
// signedUrlCache.set(s3Url, signedUrl, (expiresIn * 1000) - 60000);
|
||||
|
||||
return signedUrl;
|
||||
} catch (error) {
|
||||
|
|
@ -121,14 +120,9 @@ export async function generateSignedUrlFromS3Url(s3UrlRaw: string|null, expiresI
|
|||
* @returns The original S3 URL if found in cache, otherwise null
|
||||
*/
|
||||
export function getOriginalUrlFromSignedUrl(signedUrl: string|null): string|null {
|
||||
if (!signedUrl) {
|
||||
// Cache disabled for Workers compatibility - cannot retrieve original URL without cache
|
||||
// To re-enable, migrate signed-url-cache to object storage (R2/S3)
|
||||
return null;
|
||||
}
|
||||
|
||||
// Try to find the original URL in our cache
|
||||
const originalUrl = signedUrlCache.getOriginalUrl(signedUrl);
|
||||
|
||||
return originalUrl || null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
263
apps/backend/src/lib/signed-url-cache-old.ts
Executable file
263
apps/backend/src/lib/signed-url-cache-old.ts
Executable file
|
|
@ -0,0 +1,263 @@
|
|||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
const CACHE_FILE_PATH = path.join('.', 'assets', 'signed-url-cache.json');
|
||||
|
||||
// Interface for cache entries with TTL
|
||||
interface CacheEntry {
|
||||
value: string;
|
||||
expiresAt: number; // Timestamp when this entry expires
|
||||
}
|
||||
|
||||
class SignedURLCache {
|
||||
private originalToSignedCache: Map<string, CacheEntry>;
|
||||
private signedToOriginalCache: Map<string, CacheEntry>;
|
||||
|
||||
constructor() {
|
||||
this.originalToSignedCache = new Map();
|
||||
this.signedToOriginalCache = new Map();
|
||||
|
||||
// Create cache directory if it doesn't exist
|
||||
const cacheDir = path.dirname(CACHE_FILE_PATH);
|
||||
if (!fs.existsSync(cacheDir)) {
|
||||
console.log('creating the directory')
|
||||
|
||||
fs.mkdirSync(cacheDir, { recursive: true });
|
||||
}
|
||||
else {
|
||||
console.log('the directory is already present')
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a signed URL from the cache using an original URL as the key
|
||||
*/
|
||||
get(originalUrl: string): string | undefined {
|
||||
const entry = this.originalToSignedCache.get(originalUrl);
|
||||
|
||||
// If no entry or entry has expired, return undefined
|
||||
if (!entry || Date.now() > entry.expiresAt) {
|
||||
if (entry) {
|
||||
// Remove expired entry
|
||||
this.originalToSignedCache.delete(originalUrl);
|
||||
// Also remove from reverse mapping if it exists
|
||||
this.signedToOriginalCache.delete(entry.value);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return entry.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the original URL from the cache using a signed URL as the key
|
||||
*/
|
||||
getOriginalUrl(signedUrl: string): string | undefined {
|
||||
const entry = this.signedToOriginalCache.get(signedUrl);
|
||||
|
||||
// If no entry or entry has expired, return undefined
|
||||
if (!entry || Date.now() > entry.expiresAt) {
|
||||
if (entry) {
|
||||
// Remove expired entry
|
||||
this.signedToOriginalCache.delete(signedUrl);
|
||||
// Also remove from primary mapping if it exists
|
||||
this.originalToSignedCache.delete(entry.value);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return entry.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a value in the cache with a TTL (Time To Live)
|
||||
* @param originalUrl The original S3 URL
|
||||
* @param signedUrl The signed URL
|
||||
* @param ttlMs Time to live in milliseconds (default: 3 days)
|
||||
*/
|
||||
set(originalUrl: string, signedUrl: string, ttlMs: number = 259200000): void {
|
||||
const expiresAt = Date.now() + ttlMs;
|
||||
|
||||
const entry: CacheEntry = {
|
||||
value: signedUrl,
|
||||
expiresAt
|
||||
};
|
||||
|
||||
const reverseEntry: CacheEntry = {
|
||||
value: originalUrl,
|
||||
expiresAt
|
||||
};
|
||||
|
||||
this.originalToSignedCache.set(originalUrl, entry);
|
||||
this.signedToOriginalCache.set(signedUrl, reverseEntry);
|
||||
}
|
||||
|
||||
has(originalUrl: string): boolean {
|
||||
const entry = this.originalToSignedCache.get(originalUrl);
|
||||
|
||||
// Entry exists and hasn't expired
|
||||
return !!entry && Date.now() <= entry.expiresAt;
|
||||
}
|
||||
|
||||
hasSignedUrl(signedUrl: string): boolean {
|
||||
const entry = this.signedToOriginalCache.get(signedUrl);
|
||||
|
||||
// Entry exists and hasn't expired
|
||||
return !!entry && Date.now() <= entry.expiresAt;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.originalToSignedCache.clear();
|
||||
this.signedToOriginalCache.clear();
|
||||
this.saveToDisk();
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all expired entries from the cache
|
||||
* @returns The number of expired entries that were removed
|
||||
*/
|
||||
clearExpired(): number {
|
||||
const now = Date.now();
|
||||
let removedCount = 0;
|
||||
|
||||
// Clear expired entries from original to signed cache
|
||||
for (const [originalUrl, entry] of this.originalToSignedCache.entries()) {
|
||||
if (now > entry.expiresAt) {
|
||||
this.originalToSignedCache.delete(originalUrl);
|
||||
removedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// Clear expired entries from signed to original cache
|
||||
for (const [signedUrl, entry] of this.signedToOriginalCache.entries()) {
|
||||
if (now > entry.expiresAt) {
|
||||
this.signedToOriginalCache.delete(signedUrl);
|
||||
// No need to increment removedCount as we've already counted these in the first loop
|
||||
}
|
||||
}
|
||||
|
||||
if (removedCount > 0) {
|
||||
console.log(`SignedURLCache: Cleared ${removedCount} expired entries`);
|
||||
}
|
||||
|
||||
return removedCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the cache to disk
|
||||
*/
|
||||
saveToDisk(): void {
|
||||
try {
|
||||
// Remove expired entries before saving
|
||||
const removedCount = this.clearExpired();
|
||||
|
||||
// Convert Maps to serializable objects
|
||||
const serializedOriginalToSigned: Record<string, { value: string; expiresAt: number }> = {};
|
||||
const serializedSignedToOriginal: Record<string, { value: string; expiresAt: number }> = {};
|
||||
|
||||
for (const [originalUrl, entry] of this.originalToSignedCache.entries()) {
|
||||
serializedOriginalToSigned[originalUrl] = {
|
||||
value: entry.value,
|
||||
expiresAt: entry.expiresAt
|
||||
};
|
||||
}
|
||||
|
||||
for (const [signedUrl, entry] of this.signedToOriginalCache.entries()) {
|
||||
serializedSignedToOriginal[signedUrl] = {
|
||||
value: entry.value,
|
||||
expiresAt: entry.expiresAt
|
||||
};
|
||||
}
|
||||
|
||||
const serializedCache = {
|
||||
originalToSigned: serializedOriginalToSigned,
|
||||
signedToOriginal: serializedSignedToOriginal
|
||||
};
|
||||
|
||||
// Write to file
|
||||
fs.writeFileSync(
|
||||
CACHE_FILE_PATH,
|
||||
JSON.stringify(serializedCache),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
console.log(`SignedURLCache: Saved ${this.originalToSignedCache.size} entries to disk`);
|
||||
} catch (error) {
|
||||
console.error('Error saving SignedURLCache to disk:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the cache from disk
|
||||
*/
|
||||
loadFromDisk(): void {
|
||||
try {
|
||||
if (fs.existsSync(CACHE_FILE_PATH)) {
|
||||
// Read from file
|
||||
const data = fs.readFileSync(CACHE_FILE_PATH, 'utf8');
|
||||
|
||||
// Parse the data
|
||||
const parsedData = JSON.parse(data) as {
|
||||
originalToSigned: Record<string, { value: string; expiresAt: number }>,
|
||||
signedToOriginal: Record<string, { value: string; expiresAt: number }>
|
||||
};
|
||||
|
||||
// Only load entries that haven't expired yet
|
||||
const now = Date.now();
|
||||
let loadedCount = 0;
|
||||
let expiredCount = 0;
|
||||
|
||||
// Load original to signed mappings
|
||||
if (parsedData.originalToSigned) {
|
||||
for (const [originalUrl, entry] of Object.entries(parsedData.originalToSigned)) {
|
||||
if (now <= entry.expiresAt) {
|
||||
this.originalToSignedCache.set(originalUrl, entry);
|
||||
loadedCount++;
|
||||
} else {
|
||||
expiredCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Load signed to original mappings
|
||||
if (parsedData.signedToOriginal) {
|
||||
for (const [signedUrl, entry] of Object.entries(parsedData.signedToOriginal)) {
|
||||
if (now <= entry.expiresAt) {
|
||||
this.signedToOriginalCache.set(signedUrl, entry);
|
||||
// Don't increment loadedCount as these are pairs of what we already counted
|
||||
} else {
|
||||
// Don't increment expiredCount as these are pairs of what we already counted
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`SignedURLCache: Loaded ${loadedCount} valid entries from disk (skipped ${expiredCount} expired entries)`);
|
||||
} else {
|
||||
console.log('SignedURLCache: No cache file found, starting with empty cache');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading SignedURLCache from disk:', error);
|
||||
// Start with empty caches if loading fails
|
||||
this.originalToSignedCache = new Map();
|
||||
this.signedToOriginalCache = new Map();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create a singleton instance to be used throughout the application
|
||||
const signedUrlCache = new SignedURLCache();
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
console.log('SignedURLCache: Saving cache before shutdown...');
|
||||
signedUrlCache.saveToDisk();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on('SIGTERM', () => {
|
||||
console.log('SignedURLCache: Saving cache before shutdown...');
|
||||
signedUrlCache.saveToDisk();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
export default signedUrlCache;
|
||||
285
apps/backend/src/lib/signed-url-cache.ts
Executable file → Normal file
285
apps/backend/src/lib/signed-url-cache.ts
Executable file → Normal file
|
|
@ -1,263 +1,24 @@
|
|||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
// SIGNED URL CACHE - DISABLED
|
||||
// This file has been disabled to make the backend compatible with Cloudflare Workers.
|
||||
// File system operations are not available in the Workers environment.
|
||||
//
|
||||
// To re-enable caching, migrate to Cloudflare R2 or another object storage solution.
|
||||
// Original file saved as: signed-url-cache-old.ts
|
||||
//
|
||||
// Impact of disabling:
|
||||
// - S3 signed URLs are generated fresh on every request
|
||||
// - Increased AWS API calls (higher costs)
|
||||
// - Slightly slower image loading
|
||||
// - No file system dependencies (Workers-compatible)
|
||||
|
||||
const CACHE_FILE_PATH = path.join('.', 'assets', 'signed-url-cache.json');
|
||||
|
||||
// Interface for cache entries with TTL
|
||||
interface CacheEntry {
|
||||
value: string;
|
||||
expiresAt: number; // Timestamp when this entry expires
|
||||
}
|
||||
|
||||
class SignedURLCache {
|
||||
private originalToSignedCache: Map<string, CacheEntry>;
|
||||
private signedToOriginalCache: Map<string, CacheEntry>;
|
||||
|
||||
constructor() {
|
||||
this.originalToSignedCache = new Map();
|
||||
this.signedToOriginalCache = new Map();
|
||||
|
||||
// Create cache directory if it doesn't exist
|
||||
const cacheDir = path.dirname(CACHE_FILE_PATH);
|
||||
if (!fs.existsSync(cacheDir)) {
|
||||
console.log('creating the directory')
|
||||
|
||||
fs.mkdirSync(cacheDir, { recursive: true });
|
||||
}
|
||||
else {
|
||||
console.log('the directory is already present')
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a signed URL from the cache using an original URL as the key
|
||||
*/
|
||||
get(originalUrl: string): string | undefined {
|
||||
const entry = this.originalToSignedCache.get(originalUrl);
|
||||
|
||||
// If no entry or entry has expired, return undefined
|
||||
if (!entry || Date.now() > entry.expiresAt) {
|
||||
if (entry) {
|
||||
// Remove expired entry
|
||||
this.originalToSignedCache.delete(originalUrl);
|
||||
// Also remove from reverse mapping if it exists
|
||||
this.signedToOriginalCache.delete(entry.value);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return entry.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the original URL from the cache using a signed URL as the key
|
||||
*/
|
||||
getOriginalUrl(signedUrl: string): string | undefined {
|
||||
const entry = this.signedToOriginalCache.get(signedUrl);
|
||||
|
||||
// If no entry or entry has expired, return undefined
|
||||
if (!entry || Date.now() > entry.expiresAt) {
|
||||
if (entry) {
|
||||
// Remove expired entry
|
||||
this.signedToOriginalCache.delete(signedUrl);
|
||||
// Also remove from primary mapping if it exists
|
||||
this.originalToSignedCache.delete(entry.value);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return entry.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a value in the cache with a TTL (Time To Live)
|
||||
* @param originalUrl The original S3 URL
|
||||
* @param signedUrl The signed URL
|
||||
* @param ttlMs Time to live in milliseconds (default: 3 days)
|
||||
*/
|
||||
set(originalUrl: string, signedUrl: string, ttlMs: number = 259200000): void {
|
||||
const expiresAt = Date.now() + ttlMs;
|
||||
|
||||
const entry: CacheEntry = {
|
||||
value: signedUrl,
|
||||
expiresAt
|
||||
};
|
||||
|
||||
const reverseEntry: CacheEntry = {
|
||||
value: originalUrl,
|
||||
expiresAt
|
||||
};
|
||||
|
||||
this.originalToSignedCache.set(originalUrl, entry);
|
||||
this.signedToOriginalCache.set(signedUrl, reverseEntry);
|
||||
}
|
||||
|
||||
has(originalUrl: string): boolean {
|
||||
const entry = this.originalToSignedCache.get(originalUrl);
|
||||
|
||||
// Entry exists and hasn't expired
|
||||
return !!entry && Date.now() <= entry.expiresAt;
|
||||
}
|
||||
|
||||
hasSignedUrl(signedUrl: string): boolean {
|
||||
const entry = this.signedToOriginalCache.get(signedUrl);
|
||||
|
||||
// Entry exists and hasn't expired
|
||||
return !!entry && Date.now() <= entry.expiresAt;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.originalToSignedCache.clear();
|
||||
this.signedToOriginalCache.clear();
|
||||
this.saveToDisk();
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all expired entries from the cache
|
||||
* @returns The number of expired entries that were removed
|
||||
*/
|
||||
clearExpired(): number {
|
||||
const now = Date.now();
|
||||
let removedCount = 0;
|
||||
|
||||
// Clear expired entries from original to signed cache
|
||||
for (const [originalUrl, entry] of this.originalToSignedCache.entries()) {
|
||||
if (now > entry.expiresAt) {
|
||||
this.originalToSignedCache.delete(originalUrl);
|
||||
removedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// Clear expired entries from signed to original cache
|
||||
for (const [signedUrl, entry] of this.signedToOriginalCache.entries()) {
|
||||
if (now > entry.expiresAt) {
|
||||
this.signedToOriginalCache.delete(signedUrl);
|
||||
// No need to increment removedCount as we've already counted these in the first loop
|
||||
}
|
||||
}
|
||||
|
||||
if (removedCount > 0) {
|
||||
console.log(`SignedURLCache: Cleared ${removedCount} expired entries`);
|
||||
}
|
||||
|
||||
return removedCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the cache to disk
|
||||
*/
|
||||
saveToDisk(): void {
|
||||
try {
|
||||
// Remove expired entries before saving
|
||||
const removedCount = this.clearExpired();
|
||||
|
||||
// Convert Maps to serializable objects
|
||||
const serializedOriginalToSigned: Record<string, { value: string; expiresAt: number }> = {};
|
||||
const serializedSignedToOriginal: Record<string, { value: string; expiresAt: number }> = {};
|
||||
|
||||
for (const [originalUrl, entry] of this.originalToSignedCache.entries()) {
|
||||
serializedOriginalToSigned[originalUrl] = {
|
||||
value: entry.value,
|
||||
expiresAt: entry.expiresAt
|
||||
};
|
||||
}
|
||||
|
||||
for (const [signedUrl, entry] of this.signedToOriginalCache.entries()) {
|
||||
serializedSignedToOriginal[signedUrl] = {
|
||||
value: entry.value,
|
||||
expiresAt: entry.expiresAt
|
||||
};
|
||||
}
|
||||
|
||||
const serializedCache = {
|
||||
originalToSigned: serializedOriginalToSigned,
|
||||
signedToOriginal: serializedSignedToOriginal
|
||||
};
|
||||
|
||||
// Write to file
|
||||
fs.writeFileSync(
|
||||
CACHE_FILE_PATH,
|
||||
JSON.stringify(serializedCache),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
console.log(`SignedURLCache: Saved ${this.originalToSignedCache.size} entries to disk`);
|
||||
} catch (error) {
|
||||
console.error('Error saving SignedURLCache to disk:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the cache from disk
|
||||
*/
|
||||
loadFromDisk(): void {
|
||||
try {
|
||||
if (fs.existsSync(CACHE_FILE_PATH)) {
|
||||
// Read from file
|
||||
const data = fs.readFileSync(CACHE_FILE_PATH, 'utf8');
|
||||
|
||||
// Parse the data
|
||||
const parsedData = JSON.parse(data) as {
|
||||
originalToSigned: Record<string, { value: string; expiresAt: number }>,
|
||||
signedToOriginal: Record<string, { value: string; expiresAt: number }>
|
||||
};
|
||||
|
||||
// Only load entries that haven't expired yet
|
||||
const now = Date.now();
|
||||
let loadedCount = 0;
|
||||
let expiredCount = 0;
|
||||
|
||||
// Load original to signed mappings
|
||||
if (parsedData.originalToSigned) {
|
||||
for (const [originalUrl, entry] of Object.entries(parsedData.originalToSigned)) {
|
||||
if (now <= entry.expiresAt) {
|
||||
this.originalToSignedCache.set(originalUrl, entry);
|
||||
loadedCount++;
|
||||
} else {
|
||||
expiredCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Load signed to original mappings
|
||||
if (parsedData.signedToOriginal) {
|
||||
for (const [signedUrl, entry] of Object.entries(parsedData.signedToOriginal)) {
|
||||
if (now <= entry.expiresAt) {
|
||||
this.signedToOriginalCache.set(signedUrl, entry);
|
||||
// Don't increment loadedCount as these are pairs of what we already counted
|
||||
} else {
|
||||
// Don't increment expiredCount as these are pairs of what we already counted
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`SignedURLCache: Loaded ${loadedCount} valid entries from disk (skipped ${expiredCount} expired entries)`);
|
||||
} else {
|
||||
console.log('SignedURLCache: No cache file found, starting with empty cache');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading SignedURLCache from disk:', error);
|
||||
// Start with empty caches if loading fails
|
||||
this.originalToSignedCache = new Map();
|
||||
this.signedToOriginalCache = new Map();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create a singleton instance to be used throughout the application
|
||||
const signedUrlCache = new SignedURLCache();
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
console.log('SignedURLCache: Saving cache before shutdown...');
|
||||
signedUrlCache.saveToDisk();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on('SIGTERM', () => {
|
||||
console.log('SignedURLCache: Saving cache before shutdown...');
|
||||
signedUrlCache.saveToDisk();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
export default signedUrlCache;
|
||||
export default {
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
getOriginalUrl: () => undefined,
|
||||
has: () => false,
|
||||
hasSignedUrl: () => false,
|
||||
clear: () => {},
|
||||
clearExpired: () => 0,
|
||||
saveToDisk: () => {},
|
||||
loadFromDisk: () => {},
|
||||
};
|
||||
|
|
|
|||
294
apps/backend/src/sqliteImporter.ts
Normal file
294
apps/backend/src/sqliteImporter.ts
Normal file
|
|
@ -0,0 +1,294 @@
|
|||
// SQLite Importer - Intermediate layer to avoid direct db_helper_sqlite imports in dbService
|
||||
// This file re-exports everything from sqliteService
|
||||
|
||||
// Re-export database connection
|
||||
export { db, initDb } from 'sqliteService'
|
||||
|
||||
// Re-export all schema exports
|
||||
export * from 'sqliteService'
|
||||
|
||||
// Re-export all helper methods from sqliteService
|
||||
export {
|
||||
// Admin - Banner
|
||||
getBanners,
|
||||
getBannerById,
|
||||
createBanner,
|
||||
updateBanner,
|
||||
deleteBanner,
|
||||
// Admin - Complaint
|
||||
getComplaints,
|
||||
resolveComplaint,
|
||||
// Admin - Constants
|
||||
getAllConstants,
|
||||
upsertConstants,
|
||||
// Admin - Coupon
|
||||
getAllCoupons,
|
||||
getCouponById,
|
||||
invalidateCoupon,
|
||||
validateCoupon,
|
||||
getReservedCoupons,
|
||||
getUsersForCoupon,
|
||||
createCouponWithRelations,
|
||||
updateCouponWithRelations,
|
||||
generateCancellationCoupon,
|
||||
createReservedCouponWithProducts,
|
||||
createCouponForUser,
|
||||
checkUsersExist,
|
||||
checkCouponExists,
|
||||
checkReservedCouponExists,
|
||||
getOrderWithUser,
|
||||
// Admin - Order
|
||||
updateOrderNotes,
|
||||
getOrderDetails,
|
||||
updateOrderPackaged,
|
||||
updateOrderDelivered,
|
||||
updateOrderItemPackaging,
|
||||
removeDeliveryCharge,
|
||||
getSlotOrders,
|
||||
updateAddressCoords,
|
||||
getAllOrders,
|
||||
rebalanceSlots,
|
||||
cancelOrder,
|
||||
deleteOrderById,
|
||||
// Admin - Product
|
||||
getAllProducts,
|
||||
getProductById,
|
||||
deleteProduct,
|
||||
createProduct,
|
||||
updateProduct,
|
||||
checkProductExistsByName,
|
||||
checkUnitExists,
|
||||
getProductImagesById,
|
||||
createSpecialDealsForProduct,
|
||||
updateProductDeals,
|
||||
replaceProductTags,
|
||||
toggleProductOutOfStock,
|
||||
updateSlotProducts,
|
||||
getSlotProductIds,
|
||||
getSlotsProductIds,
|
||||
getAllUnits,
|
||||
getAllProductTags,
|
||||
getAllProductTagInfos,
|
||||
getProductTagInfoById,
|
||||
createProductTag,
|
||||
getProductTagById,
|
||||
updateProductTag,
|
||||
deleteProductTag,
|
||||
checkProductTagExistsByName,
|
||||
getProductReviews,
|
||||
respondToReview,
|
||||
getAllProductGroups,
|
||||
createProductGroup,
|
||||
updateProductGroup,
|
||||
deleteProductGroup,
|
||||
addProductToGroup,
|
||||
removeProductFromGroup,
|
||||
updateProductPrices,
|
||||
// Admin - Slots
|
||||
getActiveSlotsWithProducts,
|
||||
getActiveSlots,
|
||||
getSlotsAfterDate,
|
||||
getSlotByIdWithRelations,
|
||||
createSlotWithRelations,
|
||||
updateSlotWithRelations,
|
||||
deleteSlotById,
|
||||
updateSlotCapacity,
|
||||
getSlotDeliverySequence,
|
||||
updateSlotDeliverySequence,
|
||||
// Admin - Staff User
|
||||
getStaffUserByName,
|
||||
getStaffUserById,
|
||||
getAllStaff,
|
||||
getAllUsers,
|
||||
getUserWithDetails,
|
||||
updateUserSuspensionStatus,
|
||||
checkStaffUserExists,
|
||||
checkStaffRoleExists,
|
||||
createStaffUser,
|
||||
getAllRoles,
|
||||
// Admin - Store
|
||||
getAllStores,
|
||||
getStoreById,
|
||||
createStore,
|
||||
updateStore,
|
||||
deleteStore,
|
||||
// Admin - User
|
||||
createUserByMobile,
|
||||
getUserByMobile,
|
||||
getUnresolvedComplaintsCount,
|
||||
getAllUsersWithFilters,
|
||||
getOrderCountsByUserIds,
|
||||
getLastOrdersByUserIds,
|
||||
getSuspensionStatusesByUserIds,
|
||||
getUserBasicInfo,
|
||||
getUserSuspensionStatus,
|
||||
getUserOrders,
|
||||
getOrderStatusesByOrderIds,
|
||||
getItemCountsByOrderIds,
|
||||
upsertUserSuspension,
|
||||
searchUsers,
|
||||
getAllNotifCreds,
|
||||
getAllUnloggedTokens,
|
||||
getNotifTokensByUserIds,
|
||||
getUserIncidentsWithRelations,
|
||||
createUserIncident,
|
||||
// Admin - Vendor Snippets
|
||||
checkVendorSnippetExists,
|
||||
getVendorSnippetById,
|
||||
getVendorSnippetByCode,
|
||||
getAllVendorSnippets,
|
||||
createVendorSnippet,
|
||||
updateVendorSnippet,
|
||||
deleteVendorSnippet,
|
||||
getProductsByIds,
|
||||
getVendorSlotById,
|
||||
getVendorOrdersBySlotId,
|
||||
getOrderItemsByOrderIds,
|
||||
getOrderStatusByOrderIds,
|
||||
updateVendorOrderItemPackaging,
|
||||
getVendorOrders,
|
||||
// User - Address
|
||||
getUserDefaultAddress,
|
||||
getUserAddresses,
|
||||
getUserAddressById,
|
||||
clearUserDefaultAddress,
|
||||
createUserAddress,
|
||||
updateUserAddress,
|
||||
deleteUserAddress,
|
||||
hasOngoingOrdersForAddress,
|
||||
// User - Banners
|
||||
getUserActiveBanners,
|
||||
// User - Cart
|
||||
getUserCartItemsWithProducts,
|
||||
getUserProductById,
|
||||
getUserCartItemByUserProduct,
|
||||
incrementUserCartItemQuantity,
|
||||
insertUserCartItem,
|
||||
updateUserCartItemQuantity,
|
||||
deleteUserCartItem,
|
||||
clearUserCart,
|
||||
// User - Complaint
|
||||
getUserComplaints,
|
||||
createUserComplaint,
|
||||
// User - Stores
|
||||
getUserStoreSummaries,
|
||||
getUserStoreDetail,
|
||||
// User - Product
|
||||
getUserProductDetailById,
|
||||
getUserProductReviews,
|
||||
getUserProductByIdBasic,
|
||||
createUserProductReview,
|
||||
getAllProductsWithUnits,
|
||||
type ProductSummaryData,
|
||||
// User - Slots
|
||||
getUserActiveSlotsList,
|
||||
getUserProductAvailability,
|
||||
// User - Payments
|
||||
getUserPaymentOrderById,
|
||||
getUserPaymentByOrderId,
|
||||
getUserPaymentByMerchantOrderId,
|
||||
updateUserPaymentSuccess,
|
||||
updateUserOrderPaymentStatus,
|
||||
markUserPaymentFailed,
|
||||
// User - Auth
|
||||
getUserAuthByEmail,
|
||||
getUserAuthByMobile,
|
||||
getUserAuthById,
|
||||
getUserAuthCreds,
|
||||
getUserAuthDetails,
|
||||
isUserSuspended,
|
||||
createUserAuthWithCreds,
|
||||
createUserAuthWithMobile,
|
||||
upsertUserAuthPassword,
|
||||
deleteUserAuthAccount,
|
||||
// UV API helpers
|
||||
createUserWithProfile,
|
||||
getUserDetailsByUserId,
|
||||
updateUserProfile,
|
||||
// User - Coupon
|
||||
getUserActiveCouponsWithRelations,
|
||||
getUserAllCouponsWithRelations,
|
||||
getUserReservedCouponByCode,
|
||||
redeemUserReservedCoupon,
|
||||
// User - Profile
|
||||
getUserProfileById,
|
||||
getUserProfileDetailById,
|
||||
getUserWithCreds,
|
||||
getUserNotifCred,
|
||||
upsertUserNotifCred,
|
||||
deleteUserUnloggedToken,
|
||||
getUserUnloggedToken,
|
||||
upsertUserUnloggedToken,
|
||||
// User - Order
|
||||
validateAndGetUserCoupon,
|
||||
applyDiscountToUserOrder,
|
||||
getUserAddressByIdAndUser,
|
||||
getOrderProductById,
|
||||
checkUserSuspended,
|
||||
getUserSlotCapacityStatus,
|
||||
placeUserOrderTransaction,
|
||||
deleteUserCartItemsForOrder,
|
||||
recordUserCouponUsage,
|
||||
getUserOrdersWithRelations,
|
||||
getUserOrderCount,
|
||||
getUserOrderByIdWithRelations,
|
||||
getUserCouponUsageForOrder,
|
||||
getUserOrderBasic,
|
||||
cancelUserOrderTransaction,
|
||||
updateUserOrderNotes,
|
||||
getUserRecentlyDeliveredOrderIds,
|
||||
getUserProductIdsFromOrders,
|
||||
getUserProductsForRecentOrders,
|
||||
// Store Helpers
|
||||
getAllBannersForCache,
|
||||
getAllProductsForCache,
|
||||
getAllStoresForCache,
|
||||
getAllDeliverySlotsForCache,
|
||||
getAllSpecialDealsForCache,
|
||||
getAllProductTagsForCache,
|
||||
getAllTagsForCache,
|
||||
getAllTagProductMappings,
|
||||
getAllSlotsWithProductsForCache,
|
||||
getAllUserNegativityScores,
|
||||
getUserNegativityScore,
|
||||
type BannerData,
|
||||
type ProductBasicData,
|
||||
type StoreBasicData,
|
||||
type DeliverySlotData,
|
||||
type SpecialDealData,
|
||||
type ProductTagData,
|
||||
type TagBasicData,
|
||||
type TagProductMapping,
|
||||
type SlotWithProductsData,
|
||||
type UserNegativityData,
|
||||
// Automated Jobs
|
||||
toggleFlashDeliveryForItems,
|
||||
toggleKeyVal,
|
||||
getAllKeyValStore,
|
||||
// Post-order handler helpers
|
||||
getOrdersByIdsWithFullData,
|
||||
getOrderByIdWithFullData,
|
||||
type OrderWithFullData,
|
||||
type OrderWithCancellationData,
|
||||
// Common API helpers
|
||||
getSuspendedProductIds,
|
||||
getNextDeliveryDateWithCapacity,
|
||||
getStoresSummary,
|
||||
healthCheck,
|
||||
// Delete orders helper
|
||||
deleteOrdersWithRelations,
|
||||
// Seed helpers
|
||||
seedUnits,
|
||||
seedStaffRoles,
|
||||
seedStaffPermissions,
|
||||
seedRolePermissions,
|
||||
seedKeyValStore,
|
||||
type UnitSeedData,
|
||||
type RolePermissionAssignment,
|
||||
type KeyValSeedData,
|
||||
type StaffRoleName,
|
||||
type StaffPermissionName,
|
||||
// Upload URL Helpers
|
||||
createUploadUrlStatus,
|
||||
claimUploadUrlStatus,
|
||||
} from 'sqliteService'
|
||||
|
|
@ -661,7 +661,7 @@ export const productRouter = router({
|
|||
return {
|
||||
groups: groups.map(group => ({
|
||||
...group,
|
||||
products: group.memberships.map(m => ({
|
||||
products: group.memberships.map((m: any) => ({
|
||||
...(m.product as AdminProduct),
|
||||
images: (m.product.images as string[]) || null,
|
||||
})),
|
||||
|
|
|
|||
|
|
@ -420,9 +420,9 @@ export const vendorSnippetsRouter = router({
|
|||
productName: item.product.name,
|
||||
quantity: parseFloat(item.quantity),
|
||||
productSize: item.product.productQuantity,
|
||||
price: parseFloat(item.price.toString()),
|
||||
price: parseFloat((item.price ?? 0).toString()),
|
||||
unit: item.product.unit?.shortNotation || 'unit',
|
||||
subtotal: parseFloat(item.price.toString()) * parseFloat(item.quantity),
|
||||
subtotal: parseFloat((item.price ?? 0).toString()) * parseFloat(item.quantity),
|
||||
is_packaged: item.is_packaged,
|
||||
is_package_verified: item.is_package_verified,
|
||||
}));
|
||||
|
|
@ -604,9 +604,9 @@ export const vendorSnippetsRouter = router({
|
|||
productId: item.productId,
|
||||
productName: item.product.name,
|
||||
quantity: parseFloat(item.quantity),
|
||||
price: parseFloat(item.price.toString()),
|
||||
price: parseFloat((item.price ?? 0).toString()),
|
||||
unit: item.product.unit?.shortNotation || 'unit',
|
||||
subtotal: parseFloat(item.price.toString()) * parseFloat(item.quantity),
|
||||
subtotal: parseFloat((item.price ?? 0).toString()) * parseFloat(item.quantity),
|
||||
productSize: item.product.productQuantity,
|
||||
is_packaged: item.is_packaged,
|
||||
is_package_verified: item.is_package_verified,
|
||||
|
|
|
|||
|
|
@ -110,7 +110,9 @@ export const authRouter = router({
|
|||
createdAt: foundUser.createdAt.toISOString(),
|
||||
profileImage: profileImageSignedUrl,
|
||||
bio: userDetail?.bio || null,
|
||||
dateOfBirth: userDetail?.dateOfBirth || null,
|
||||
dateOfBirth: userDetail?.dateOfBirth
|
||||
? new Date(userDetail.dateOfBirth as any).toISOString()
|
||||
: null,
|
||||
gender: userDetail?.gender || null,
|
||||
occupation: userDetail?.occupation || null,
|
||||
},
|
||||
|
|
@ -370,7 +372,9 @@ export const authRouter = router({
|
|||
createdAt: updatedUser.createdAt?.toISOString?.() || new Date().toISOString(),
|
||||
profileImage: profileImageSignedUrl,
|
||||
bio: userDetail?.bio || null,
|
||||
dateOfBirth: userDetail?.dateOfBirth || null,
|
||||
dateOfBirth: userDetail?.dateOfBirth
|
||||
? new Date(userDetail.dateOfBirth as any).toISOString()
|
||||
: null,
|
||||
gender: userDetail?.gender || null,
|
||||
occupation: userDetail?.occupation || null,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,25 +1,29 @@
|
|||
import { router, protectedProcedure } from "@/src/trpc/trpc-index";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
validateAndGetUserCoupon,
|
||||
applyDiscountToUserOrder,
|
||||
getUserAddressByIdAndUser,
|
||||
getOrderProductById,
|
||||
cancelUserOrderTransaction,
|
||||
checkUserSuspended,
|
||||
getUserSlotCapacityStatus,
|
||||
placeUserOrderTransaction,
|
||||
db,
|
||||
deleteUserCartItemsForOrder,
|
||||
recordUserCouponUsage,
|
||||
getUserOrdersWithRelations,
|
||||
getUserOrderCount,
|
||||
getUserOrderByIdWithRelations,
|
||||
getOrderProductById,
|
||||
getUserAddressByIdAndUser,
|
||||
getUserCouponUsageForOrder,
|
||||
getUserOrderBasic,
|
||||
cancelUserOrderTransaction,
|
||||
updateUserOrderNotes,
|
||||
getUserRecentlyDeliveredOrderIds,
|
||||
getUserOrderByIdWithRelations,
|
||||
getUserOrderCount,
|
||||
getUserOrdersWithRelations,
|
||||
getUserProductIdsFromOrders,
|
||||
getUserProductsForRecentOrders,
|
||||
getUserRecentlyDeliveredOrderIds,
|
||||
getUserSlotCapacityStatus,
|
||||
orders,
|
||||
orderItems,
|
||||
orderStatus,
|
||||
placeUserOrderTransaction,
|
||||
recordUserCouponUsage,
|
||||
updateUserOrderNotes,
|
||||
validateAndGetUserCoupon,
|
||||
} from "@/src/dbService";
|
||||
import { getNextDeliveryDate } from "@/src/trpc/apis/common-apis/common";
|
||||
import { scaffoldAssetUrl } from "@/src/lib/s3-client";
|
||||
|
|
@ -115,9 +119,10 @@ const placeOrderUtil = async (params: {
|
|||
const orderTotal = items.reduce(
|
||||
(sum, item) => {
|
||||
if (!item.product) return sum
|
||||
const itemPrice = params.isFlash
|
||||
? parseFloat((item.product.flashPrice || item.product.price).toString())
|
||||
: parseFloat(item.product.price.toString());
|
||||
const basePrice = params.isFlash
|
||||
? (item.product.flashPrice ?? item.product.price)
|
||||
: item.product.price
|
||||
const itemPrice = parseFloat((basePrice ?? 0).toString())
|
||||
return sum + itemPrice * item.quantity;
|
||||
},
|
||||
0
|
||||
|
|
@ -132,9 +137,6 @@ const placeOrderUtil = async (params: {
|
|||
|
||||
const totalWithDelivery = totalAmount + expectedDeliveryCharge;
|
||||
|
||||
const { db } = await import("postgresService");
|
||||
const { orders, orderItems, orderStatus } = await import("postgresService");
|
||||
|
||||
type OrderData = {
|
||||
order: Omit<typeof orders.$inferInsert, "id">;
|
||||
orderItems: Omit<typeof orderItems.$inferInsert, "id">[];
|
||||
|
|
@ -148,9 +150,10 @@ const placeOrderUtil = async (params: {
|
|||
const subOrderTotal = items.reduce(
|
||||
(sum, item) => {
|
||||
if (!item.product) return sum
|
||||
const itemPrice = params.isFlash
|
||||
? parseFloat((item.product.flashPrice || item.product.price).toString())
|
||||
: parseFloat(item.product.price.toString());
|
||||
const basePrice = params.isFlash
|
||||
? (item.product.flashPrice ?? item.product.price)
|
||||
: item.product.price
|
||||
const itemPrice = parseFloat((basePrice ?? 0).toString())
|
||||
return sum + itemPrice * item.quantity;
|
||||
},
|
||||
0
|
||||
|
|
@ -182,22 +185,25 @@ const placeOrderUtil = async (params: {
|
|||
isFlashDelivery: params.isFlash,
|
||||
};
|
||||
|
||||
const orderItemsData: Omit<typeof orderItems.$inferInsert, "id">[] = items
|
||||
.filter((item) => item.product !== null && item.product !== undefined)
|
||||
.map(
|
||||
(item) => ({
|
||||
const validItems = items.filter(
|
||||
(item): item is typeof item & { product: NonNullable<typeof item.product> } =>
|
||||
item.product !== null && item.product !== undefined
|
||||
)
|
||||
const orderItemsData: Omit<typeof orderItems.$inferInsert, "id">[] = validItems.map(
|
||||
(item) => {
|
||||
const basePrice = params.isFlash
|
||||
? (item.product.flashPrice ?? item.product.price)
|
||||
: item.product.price
|
||||
const priceString = (basePrice ?? 0).toString()
|
||||
|
||||
return {
|
||||
orderId: 0,
|
||||
productId: item.productId,
|
||||
quantity: item.quantity.toString(),
|
||||
price: params.isFlash
|
||||
? item.product!.flashPrice || item.product!.price
|
||||
: item.product!.price,
|
||||
discountedPrice: (
|
||||
params.isFlash
|
||||
? item.product!.flashPrice || item.product!.price
|
||||
: item.product!.price
|
||||
).toString(),
|
||||
})
|
||||
price: priceString,
|
||||
discountedPrice: priceString,
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const orderStatusData: Omit<typeof orderStatus.$inferInsert, "id"> = {
|
||||
|
|
|
|||
|
|
@ -66,6 +66,9 @@ export const paymentRouter = router({
|
|||
}
|
||||
|
||||
// Create Razorpay order and insert payment record
|
||||
if (order.totalAmount === null) {
|
||||
throw new ApiError('Order total is missing', 400)
|
||||
}
|
||||
const razorpayOrder = await RazorpayPaymentService.createOrder(parseInt(orderId), order.totalAmount);
|
||||
await RazorpayPaymentService.insertPaymentRecord(parseInt(orderId), razorpayOrder);
|
||||
|
||||
|
|
|
|||
|
|
@ -59,7 +59,9 @@ export const userRouter = router({
|
|||
mobile: user.mobile,
|
||||
profileImage: profileImageSignedUrl,
|
||||
bio: userDetail?.bio || null,
|
||||
dateOfBirth: userDetail?.dateOfBirth || null,
|
||||
dateOfBirth: userDetail?.dateOfBirth
|
||||
? new Date(userDetail.dateOfBirth as any).toISOString()
|
||||
: null,
|
||||
gender: userDetail?.gender || null,
|
||||
occupation: userDetail?.occupation || null,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -35,8 +35,10 @@
|
|||
"@commonTypes/*": ["../../packages/ui/shared-types/*"],
|
||||
"@packages/shared": ["../../packages/shared"],
|
||||
"@packages/shared/*": ["../../packages/shared/*"],
|
||||
"postgresService": ["../../packages/db_helper_postgres"],
|
||||
"postgresService/*": ["../../packages/db_helper_postgres/*"],
|
||||
// "postgresService": ["../../packages/db_helper_postgres"],
|
||||
// "postgresService/*": ["../../packages/db_helper_postgres/*"],
|
||||
"sqliteService": ["../../packages/db_helper_sqlite"],
|
||||
"sqliteService/*": ["../../packages/db_helper_sqlite/*"],
|
||||
"global-shared": ["../../packages/shared"],
|
||||
"global-shared/*": ["../../packages/shared/*"]
|
||||
},
|
||||
|
|
@ -122,6 +124,5 @@
|
|||
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
|
||||
"skipLibCheck": true /* Skip type checking all .d.ts files. */
|
||||
},
|
||||
"include": ["src", "types", "index.ts", "../shared-types", "../../packages/shared"]
|
||||
"include": ["src", "types", "index.ts", "worker.ts", "../shared-types", "../../packages/shared"]
|
||||
}
|
||||
|
||||
|
|
|
|||
10
apps/backend/worker.ts
Normal file
10
apps/backend/worker.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import type { ExecutionContext } from '@cloudflare/workers-types'
|
||||
|
||||
export default {
|
||||
async fetch(request: Request, env: Record<string, string>, ctx: ExecutionContext) {
|
||||
;(globalThis as any).ENV = env
|
||||
const { createApp } = await import('./src/app')
|
||||
const app = createApp()
|
||||
return app.fetch(request, env, ctx)
|
||||
},
|
||||
}
|
||||
32
apps/backend/wrangler.toml
Normal file
32
apps/backend/wrangler.toml
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
name = "freshyo-backend"
|
||||
main = "worker.ts"
|
||||
compatibility_date = "2024-12-01"
|
||||
compatibility_flags = ["nodejs_compat"]
|
||||
|
||||
[vars]
|
||||
APP_URL = "http://localhost:4000"
|
||||
JWT_SECRET = "your-jwt-secret"
|
||||
S3_ACCESS_KEY_ID = ""
|
||||
S3_SECRET_ACCESS_KEY = ""
|
||||
S3_BUCKET_NAME = ""
|
||||
S3_REGION = ""
|
||||
ASSETS_DOMAIN = ""
|
||||
API_CACHE_KEY = ""
|
||||
CLOUDFLARE_API_TOKEN = ""
|
||||
CLOUDFLARE_ZONE_ID = ""
|
||||
S3_URL = ""
|
||||
REDIS_URL = ""
|
||||
EXPO_ACCESS_TOKEN = ""
|
||||
PHONE_PE_BASE_URL = ""
|
||||
PHONE_PE_CLIENT_ID = ""
|
||||
PHONE_PE_CLIENT_VERSION = ""
|
||||
PHONE_PE_CLIENT_SECRET = ""
|
||||
PHONE_PE_MERCHANT_ID = ""
|
||||
RAZORPAY_KEY = ""
|
||||
RAZORPAY_SECRET = ""
|
||||
OTP_SENDER_AUTH_TOKEN = ""
|
||||
MIN_ORDER_VALUE = ""
|
||||
DELIVERY_CHARGE = ""
|
||||
TELEGRAM_BOT_TOKEN = ""
|
||||
TELEGRAM_CHAT_IDS = ""
|
||||
ENV_MODE = "dev"
|
||||
|
|
@ -487,7 +487,7 @@ export async function getUsersForCoupon(
|
|||
})
|
||||
|
||||
return {
|
||||
users: userList.map((user: typeof users.$inferSelect) => ({
|
||||
users: userList.map((user) => ({
|
||||
id: user.id,
|
||||
name: user.name || 'Unknown',
|
||||
mobile: user.mobile,
|
||||
|
|
|
|||
|
|
@ -143,7 +143,7 @@ export async function getOrderDetails(orderId: number): Promise<AdminOrderDetail
|
|||
let couponData = null
|
||||
if (couponUsageData.length > 0) {
|
||||
let totalDiscountAmount = 0
|
||||
const orderTotal = parseFloat(orderData.totalAmount.toString())
|
||||
const orderTotal = parseFloat((orderData.totalAmount ?? '0').toString())
|
||||
|
||||
for (const usage of couponUsageData) {
|
||||
let discountAmount = 0
|
||||
|
|
|
|||
|
|
@ -407,7 +407,7 @@ export async function getOrdersWithRelations(
|
|||
},
|
||||
},
|
||||
},
|
||||
orderBy: (ordersTable: typeof orders) => [desc(ordersTable.createdAt)],
|
||||
orderBy: [desc(orders.createdAt)],
|
||||
limit: pageSize,
|
||||
offset: offset,
|
||||
}) as Promise<OrderWithRelations[]>
|
||||
|
|
|
|||
BIN
packages/migrated.db
Normal file
BIN
packages/migrated.db
Normal file
Binary file not shown.
84
packages/migrator/README.md
Normal file
84
packages/migrator/README.md
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
# @packages/migrator
|
||||
|
||||
Database migration tool for moving data between PostgreSQL and SQLite.
|
||||
|
||||
## Setup
|
||||
|
||||
Install dependencies:
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Edit `src/config.ts` directly to configure database settings:
|
||||
|
||||
```typescript
|
||||
// PostgreSQL Configuration
|
||||
export const postgresConfig = {
|
||||
connectionString: 'postgresql://postgres:postgres@localhost:5432/freshyo',
|
||||
ssl: false,
|
||||
};
|
||||
|
||||
// SQLite Configuration
|
||||
export const sqliteConfig = {
|
||||
filename: './data/migrated.db',
|
||||
};
|
||||
|
||||
// Migration Settings
|
||||
export const migrationConfig = {
|
||||
batchSize: 1000, // Rows per batch
|
||||
truncateBeforeInsert: true, // Clear tables before migration
|
||||
excludedTables: [], // Tables to skip
|
||||
includedTables: [], // Tables to include (empty = all)
|
||||
};
|
||||
|
||||
// Logging
|
||||
export const logConfig = {
|
||||
verbose: true,
|
||||
logFile: './migration.log',
|
||||
};
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### PostgreSQL to SQLite
|
||||
|
||||
Migrate data from PostgreSQL to SQLite:
|
||||
|
||||
```bash
|
||||
npm run migrate:pg-to-sqlite
|
||||
```
|
||||
|
||||
### SQLite to PostgreSQL
|
||||
|
||||
Migrate data from SQLite to PostgreSQL:
|
||||
|
||||
```bash
|
||||
npm run migrate:sqlite-to-pg
|
||||
```
|
||||
|
||||
### Full Cycle (Testing)
|
||||
|
||||
Run both migrations in sequence:
|
||||
|
||||
```bash
|
||||
npm run migrate:full-cycle
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- ✅ Automatic schema conversion between PostgreSQL and SQLite
|
||||
- ✅ Batch processing for large datasets
|
||||
- ✅ Type mapping between databases
|
||||
- ✅ JSON/array handling
|
||||
- ✅ Configurable table filtering
|
||||
- ✅ Progress logging
|
||||
- ✅ Transaction support
|
||||
|
||||
## Notes
|
||||
|
||||
- Arrays and JSON data are stored as TEXT in SQLite and parsed back when migrating to PostgreSQL
|
||||
- Date/timestamps are stored as ISO strings in SQLite
|
||||
- Foreign key constraints are enabled in SQLite
|
||||
- Edit `src/config.ts` to change any settings
|
||||
BIN
packages/migrator/data/migrated.db
Normal file
BIN
packages/migrator/data/migrated.db
Normal file
Binary file not shown.
26
packages/migrator/package.json
Normal file
26
packages/migrator/package.json
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
{
|
||||
"name": "@packages/migrator",
|
||||
"version": "1.0.0",
|
||||
"description": "Database migration tool between PostgreSQL and SQLite",
|
||||
"main": "index.ts",
|
||||
"types": "index.ts",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"migrate:pg-to-sqlite": "tsx src/postgresToSqlite/index.ts",
|
||||
"migrate:sqlite-to-pg": "tsx src/sqliteToPostgres/index.ts",
|
||||
"migrate:full-cycle": "npm run migrate:pg-to-sqlite && npm run migrate:sqlite-to-pg"
|
||||
},
|
||||
"dependencies": {
|
||||
"better-sqlite3": "^12.1.0",
|
||||
"dotenv": "^17.2.1",
|
||||
"drizzle-orm": "^0.44.5",
|
||||
"pg": "^8.16.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/node": "^24.5.2",
|
||||
"@types/pg": "^8.15.5",
|
||||
"tsx": "^4.20.5",
|
||||
"typescript": "^5.9.2"
|
||||
}
|
||||
}
|
||||
36
packages/migrator/src/config.ts
Normal file
36
packages/migrator/src/config.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
/**
|
||||
* Database migration configuration
|
||||
* Edit this file directly to configure migration settings
|
||||
*/
|
||||
|
||||
// PostgreSQL Configuration
|
||||
export const postgresConfig = {
|
||||
connectionString: 'postgresql://postgres:meatfarmer_master_password@57.128.212.174:7447/meatfarmer',
|
||||
ssl: false as boolean | { rejectUnauthorized: boolean },
|
||||
};
|
||||
|
||||
// SQLite Configuration
|
||||
export const sqliteConfig = {
|
||||
filename: './data/migrated.db',
|
||||
};
|
||||
|
||||
// Migration Settings
|
||||
export const migrationConfig = {
|
||||
// Batch size for bulk inserts (to avoid memory issues)
|
||||
batchSize: 1000,
|
||||
|
||||
// Enable/disable table truncation before migration
|
||||
truncateBeforeInsert: true,
|
||||
|
||||
// Tables to exclude from migration
|
||||
excludedTables: [] as string[],
|
||||
|
||||
// Tables to include (if empty, includes all)
|
||||
includedTables: [] as string[],
|
||||
};
|
||||
|
||||
// Logging
|
||||
export const logConfig = {
|
||||
verbose: true,
|
||||
logFile: './migration.log',
|
||||
};
|
||||
288
packages/migrator/src/postgresToSqlite/index.ts
Normal file
288
packages/migrator/src/postgresToSqlite/index.ts
Normal file
|
|
@ -0,0 +1,288 @@
|
|||
import { Client } from 'pg';
|
||||
import Database from 'better-sqlite3';
|
||||
import { postgresConfig, sqliteConfig, migrationConfig, logConfig } from '../config';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
interface TableInfo {
|
||||
tableName: string;
|
||||
columns: ColumnInfo[];
|
||||
}
|
||||
|
||||
interface ColumnInfo {
|
||||
name: string;
|
||||
type: string;
|
||||
isNullable: boolean;
|
||||
defaultValue: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps PostgreSQL data types to SQLite data types
|
||||
*/
|
||||
function mapPostgresTypeToSqlite(pgType: string): string {
|
||||
const typeMap: Record<string, string> = {
|
||||
'bigint': 'INTEGER',
|
||||
'bigserial': 'INTEGER',
|
||||
'boolean': 'INTEGER',
|
||||
'character': 'TEXT',
|
||||
'character varying': 'TEXT',
|
||||
'date': 'TEXT',
|
||||
'double precision': 'REAL',
|
||||
'integer': 'INTEGER',
|
||||
'json': 'TEXT',
|
||||
'jsonb': 'TEXT',
|
||||
'numeric': 'REAL',
|
||||
'real': 'REAL',
|
||||
'serial': 'INTEGER',
|
||||
'smallint': 'INTEGER',
|
||||
'text': 'TEXT',
|
||||
'timestamp with time zone': 'TEXT',
|
||||
'timestamp without time zone': 'TEXT',
|
||||
'uuid': 'TEXT',
|
||||
'ARRAY': 'TEXT', // Arrays stored as JSON text
|
||||
};
|
||||
|
||||
// Check for array types
|
||||
if (pgType.endsWith('[]')) {
|
||||
return 'TEXT';
|
||||
}
|
||||
|
||||
return typeMap[pgType] || 'TEXT';
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all table names from PostgreSQL
|
||||
*/
|
||||
async function getPostgresTables(client: Client): Promise<string[]> {
|
||||
const result = await client.query(`
|
||||
SELECT table_name
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
AND table_type = 'BASE TABLE'
|
||||
ORDER BY table_name
|
||||
`);
|
||||
|
||||
return result.rows.map(row => row.table_name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets column information for a specific table
|
||||
*/
|
||||
async function getTableColumns(client: Client, tableName: string): Promise<ColumnInfo[]> {
|
||||
const result = await client.query(`
|
||||
SELECT
|
||||
column_name,
|
||||
data_type,
|
||||
is_nullable,
|
||||
column_default
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = $1
|
||||
AND table_schema = 'public'
|
||||
ORDER BY ordinal_position
|
||||
`, [tableName]);
|
||||
|
||||
return result.rows.map(row => ({
|
||||
name: row.column_name,
|
||||
type: row.data_type,
|
||||
isNullable: row.is_nullable === 'YES',
|
||||
defaultValue: row.column_default,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates SQLite table based on PostgreSQL schema
|
||||
*/
|
||||
function createSqliteTable(db: Database.Database, tableName: string, columns: ColumnInfo[]): void {
|
||||
const columnDefs = columns.map(col => {
|
||||
let def = `"${col.name}" ${mapPostgresTypeToSqlite(col.type)}`;
|
||||
if (!col.isNullable) {
|
||||
def += ' NOT NULL';
|
||||
}
|
||||
if (col.defaultValue !== null) {
|
||||
// Convert PostgreSQL default values to SQLite
|
||||
let defaultVal = col.defaultValue;
|
||||
if (defaultVal.includes('nextval')) {
|
||||
// Skip auto-increment defaults, SQLite handles this with INTEGER PRIMARY KEY
|
||||
} else if (defaultVal === 'now()' || defaultVal.includes('CURRENT_TIMESTAMP')) {
|
||||
def += ` DEFAULT CURRENT_TIMESTAMP`;
|
||||
} else {
|
||||
def += ` DEFAULT ${defaultVal}`;
|
||||
}
|
||||
}
|
||||
return def;
|
||||
}).join(', ');
|
||||
|
||||
const createSql = `CREATE TABLE IF NOT EXISTS "${tableName}" (${columnDefs})`;
|
||||
|
||||
if (logConfig.verbose) {
|
||||
console.log(`Creating table: ${tableName}`);
|
||||
console.log(createSql);
|
||||
}
|
||||
|
||||
db.exec(createSql);
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrates data from PostgreSQL to SQLite
|
||||
*/
|
||||
async function migrateTableData(
|
||||
pgClient: Client,
|
||||
sqliteDb: Database.Database,
|
||||
tableName: string,
|
||||
columns: ColumnInfo[]
|
||||
): Promise<number> {
|
||||
// Check if table should be excluded
|
||||
if (migrationConfig.excludedTables.includes(tableName)) {
|
||||
console.log(`Skipping excluded table: ${tableName}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Check if only specific tables should be included
|
||||
if (migrationConfig.includedTables.length > 0 && !migrationConfig.includedTables.includes(tableName)) {
|
||||
console.log(`Skipping table not in include list: ${tableName}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
console.log(`Migrating table: ${tableName}`);
|
||||
|
||||
// Get total count first
|
||||
const countResult = await pgClient.query(`SELECT COUNT(*) FROM "${tableName}"`);
|
||||
const totalRows = parseInt(countResult.rows[0].count);
|
||||
console.log(` Total rows to migrate: ${totalRows}`);
|
||||
|
||||
if (totalRows === 0) {
|
||||
console.log(` No data to migrate`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Clear existing data if configured
|
||||
if (migrationConfig.truncateBeforeInsert) {
|
||||
sqliteDb.exec(`DELETE FROM "${tableName}"`);
|
||||
console.log(` Cleared existing data`);
|
||||
}
|
||||
|
||||
// Get column names
|
||||
const columnNames = columns.map(c => `"${c.name}"`).join(', ');
|
||||
const placeholders = columns.map(() => '?').join(', ');
|
||||
const insertStmt = sqliteDb.prepare(`INSERT INTO "${tableName}" (${columnNames}) VALUES (${placeholders})`);
|
||||
|
||||
let migratedCount = 0;
|
||||
let offset = 0;
|
||||
|
||||
while (offset < totalRows) {
|
||||
const result = await pgClient.query(
|
||||
`SELECT * FROM "${tableName}" ORDER BY 1 LIMIT $1 OFFSET $2`,
|
||||
[migrationConfig.batchSize, offset]
|
||||
);
|
||||
|
||||
const insertMany = sqliteDb.transaction((rows) => {
|
||||
for (const row of rows) {
|
||||
const values = columns.map(col => {
|
||||
const val = row[col.name];
|
||||
// Handle special cases
|
||||
if (val === null || val === undefined) return null;
|
||||
if (typeof val === 'boolean') return val ? 1 : 0;
|
||||
if (val instanceof Date) return val.toISOString();
|
||||
if (Array.isArray(val)) return JSON.stringify(val);
|
||||
if (typeof val === 'object') return JSON.stringify(val);
|
||||
// Ensure it's a primitive type SQLite can handle
|
||||
if (typeof val === 'number') return val;
|
||||
if (typeof val === 'bigint') return Number(val);
|
||||
return String(val);
|
||||
});
|
||||
insertStmt.run(values);
|
||||
}
|
||||
});
|
||||
|
||||
insertMany(result.rows);
|
||||
migratedCount += result.rows.length;
|
||||
offset += migrationConfig.batchSize;
|
||||
|
||||
if (logConfig.verbose || offset % 10000 === 0) {
|
||||
console.log(` Progress: ${migratedCount}/${totalRows} rows`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(` Completed: ${migratedCount} rows migrated`);
|
||||
return migratedCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Main migration function
|
||||
*/
|
||||
async function migratePostgresToSqlite(): Promise<void> {
|
||||
console.log('Starting PostgreSQL to SQLite migration...\n');
|
||||
|
||||
// Ensure SQLite directory exists
|
||||
const sqliteDir = path.dirname(sqliteConfig.filename);
|
||||
if (!fs.existsSync(sqliteDir)) {
|
||||
fs.mkdirSync(sqliteDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Remove existing SQLite file if starting fresh
|
||||
if (migrationConfig.truncateBeforeInsert && fs.existsSync(sqliteConfig.filename)) {
|
||||
fs.unlinkSync(sqliteConfig.filename);
|
||||
console.log('Removed existing SQLite database');
|
||||
}
|
||||
|
||||
// Connect to PostgreSQL
|
||||
const pgClient = new Client(postgresConfig);
|
||||
await pgClient.connect();
|
||||
console.log('Connected to PostgreSQL');
|
||||
|
||||
// Connect to SQLite
|
||||
const sqliteDb = new Database(sqliteConfig.filename);
|
||||
console.log('Connected to SQLite');
|
||||
|
||||
// Enable foreign keys
|
||||
sqliteDb.exec('PRAGMA foreign_keys = ON');
|
||||
|
||||
try {
|
||||
// Get all tables
|
||||
const tables = await getPostgresTables(pgClient);
|
||||
console.log(`\nFound ${tables.length} tables to migrate\n`);
|
||||
|
||||
let totalMigrated = 0;
|
||||
|
||||
// Migrate each table
|
||||
for (const tableName of tables) {
|
||||
try {
|
||||
const columns = await getTableColumns(pgClient, tableName);
|
||||
|
||||
// Create table in SQLite
|
||||
createSqliteTable(sqliteDb, tableName, columns);
|
||||
|
||||
// Migrate data
|
||||
const migrated = await migrateTableData(pgClient, sqliteDb, tableName, columns);
|
||||
totalMigrated += migrated;
|
||||
|
||||
console.log('');
|
||||
} catch (error) {
|
||||
console.error(`Error migrating table ${tableName}:`, error);
|
||||
if (logConfig.verbose) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('=================================');
|
||||
console.log('Migration completed successfully!');
|
||||
console.log(`Total rows migrated: ${totalMigrated}`);
|
||||
console.log(`SQLite database: ${sqliteConfig.filename}`);
|
||||
console.log('=================================');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Migration failed:', error);
|
||||
throw error;
|
||||
} finally {
|
||||
await pgClient.end();
|
||||
sqliteDb.close();
|
||||
}
|
||||
}
|
||||
|
||||
// Run migration if called directly
|
||||
if (require.main === module) {
|
||||
migratePostgresToSqlite().catch(console.error);
|
||||
}
|
||||
|
||||
export { migratePostgresToSqlite };
|
||||
273
packages/migrator/src/sqliteToPostgres/index.ts
Normal file
273
packages/migrator/src/sqliteToPostgres/index.ts
Normal file
|
|
@ -0,0 +1,273 @@
|
|||
import { Client } from 'pg';
|
||||
import Database from 'better-sqlite3';
|
||||
import { postgresConfig, sqliteConfig, migrationConfig, logConfig } from '../config';
|
||||
|
||||
interface TableInfo {
|
||||
tableName: string;
|
||||
columns: ColumnInfo[];
|
||||
}
|
||||
|
||||
interface ColumnInfo {
|
||||
name: string;
|
||||
type: string;
|
||||
notNull: boolean;
|
||||
defaultValue: string | null;
|
||||
primaryKey: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps SQLite data types back to PostgreSQL data types
|
||||
* This is a best-effort mapping and may need adjustment based on specific use cases
|
||||
*/
|
||||
function mapSqliteTypeToPostgres(sqliteType: string): string {
|
||||
const typeMap: Record<string, string> = {
|
||||
'INTEGER': 'INTEGER',
|
||||
'REAL': 'DOUBLE PRECISION',
|
||||
'TEXT': 'TEXT',
|
||||
'BLOB': 'BYTEA',
|
||||
'NUMERIC': 'NUMERIC',
|
||||
};
|
||||
|
||||
return typeMap[sqliteType.toUpperCase()] || 'TEXT';
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all table names from SQLite
|
||||
*/
|
||||
function getSqliteTables(db: Database.Database): string[] {
|
||||
const result = db.prepare(`
|
||||
SELECT name FROM sqlite_master
|
||||
WHERE type = 'table'
|
||||
AND name NOT LIKE 'sqlite_%'
|
||||
ORDER BY name
|
||||
`).all() as { name: string }[];
|
||||
|
||||
return result.map(row => row.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets column information for a specific table from SQLite
|
||||
*/
|
||||
function getSqliteTableColumns(db: Database.Database, tableName: string): ColumnInfo[] {
|
||||
const result = db.prepare(`PRAGMA table_info("${tableName}")`).all() as any[];
|
||||
|
||||
return result.map(row => ({
|
||||
name: row.name,
|
||||
type: row.type,
|
||||
notNull: row.notnull === 1,
|
||||
defaultValue: row.dflt_value,
|
||||
primaryKey: row.pk === 1,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates PostgreSQL table based on SQLite schema
|
||||
*/
|
||||
async function createPostgresTable(
|
||||
pgClient: Client,
|
||||
tableName: string,
|
||||
columns: ColumnInfo[]
|
||||
): Promise<void> {
|
||||
// Check if table exists
|
||||
const existsResult = await pgClient.query(`
|
||||
SELECT EXISTS (
|
||||
SELECT FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = $1
|
||||
)
|
||||
`, [tableName]);
|
||||
|
||||
const tableExists = existsResult.rows[0].exists;
|
||||
|
||||
if (tableExists && migrationConfig.truncateBeforeInsert) {
|
||||
// Drop existing table to recreate
|
||||
await pgClient.query(`DROP TABLE IF EXISTS "${tableName}" CASCADE`);
|
||||
console.log(` Dropped existing table: ${tableName}`);
|
||||
} else if (tableExists) {
|
||||
console.log(` Table already exists, will append data: ${tableName}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const columnDefs = columns.map(col => {
|
||||
let def = `"${col.name}" ${mapSqliteTypeToPostgres(col.type)}`;
|
||||
if (col.notNull) {
|
||||
def += ' NOT NULL';
|
||||
}
|
||||
if (col.primaryKey) {
|
||||
def += ' PRIMARY KEY';
|
||||
}
|
||||
if (col.defaultValue !== null) {
|
||||
def += ` DEFAULT ${col.defaultValue}`;
|
||||
}
|
||||
return def;
|
||||
}).join(', ');
|
||||
|
||||
const createSql = `CREATE TABLE "${tableName}" (${columnDefs})`;
|
||||
|
||||
if (logConfig.verbose) {
|
||||
console.log(`Creating table: ${tableName}`);
|
||||
console.log(createSql);
|
||||
}
|
||||
|
||||
await pgClient.query(createSql);
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to parse JSON values that were stored as TEXT in SQLite
|
||||
*/
|
||||
function parseValue(value: any, columnName: string): any {
|
||||
if (value === null) return null;
|
||||
if (typeof value !== 'string') return value;
|
||||
|
||||
// Try to parse as JSON (for arrays/objects that were stringified)
|
||||
if ((value.startsWith('[') && value.endsWith(']')) ||
|
||||
(value.startsWith('{') && value.endsWith('}'))) {
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrates data from SQLite to PostgreSQL
|
||||
*/
|
||||
async function migrateTableData(
|
||||
sqliteDb: Database.Database,
|
||||
pgClient: Client,
|
||||
tableName: string,
|
||||
columns: ColumnInfo[]
|
||||
): Promise<number> {
|
||||
// Check if table should be excluded
|
||||
if (migrationConfig.excludedTables.includes(tableName)) {
|
||||
console.log(`Skipping excluded table: ${tableName}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Check if only specific tables should be included
|
||||
if (migrationConfig.includedTables.length > 0 && !migrationConfig.includedTables.includes(tableName)) {
|
||||
console.log(`Skipping table not in include list: ${tableName}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
console.log(`Migrating table: ${tableName}`);
|
||||
|
||||
// Get total count
|
||||
const countResult = sqliteDb.prepare(`SELECT COUNT(*) as count FROM "${tableName}"`).get() as { count: number };
|
||||
const totalRows = countResult.count;
|
||||
console.log(` Total rows to migrate: ${totalRows}`);
|
||||
|
||||
if (totalRows === 0) {
|
||||
console.log(` No data to migrate`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Clear existing data if configured (and table wasn't just created)
|
||||
if (migrationConfig.truncateBeforeInsert) {
|
||||
await pgClient.query(`DELETE FROM "${tableName}"`);
|
||||
console.log(` Cleared existing data`);
|
||||
}
|
||||
|
||||
// Get column names
|
||||
const columnNames = columns.map(c => `"${c.name}"`).join(', ');
|
||||
const placeholders = columns.map((_, i) => `$${i + 1}`).join(', ');
|
||||
|
||||
let migratedCount = 0;
|
||||
let offset = 0;
|
||||
|
||||
while (offset < totalRows) {
|
||||
const rows = sqliteDb.prepare(`
|
||||
SELECT * FROM "${tableName}"
|
||||
ORDER BY ROWID
|
||||
LIMIT ? OFFSET ?
|
||||
`).all(migrationConfig.batchSize, offset) as any[];
|
||||
|
||||
for (const row of rows) {
|
||||
const values = columns.map(col => parseValue(row[col.name], col.name));
|
||||
|
||||
await pgClient.query(
|
||||
`INSERT INTO "${tableName}" (${columnNames}) VALUES (${placeholders})`,
|
||||
values
|
||||
);
|
||||
}
|
||||
|
||||
migratedCount += rows.length;
|
||||
offset += migrationConfig.batchSize;
|
||||
|
||||
if (logConfig.verbose || offset % 10000 === 0) {
|
||||
console.log(` Progress: ${migratedCount}/${totalRows} rows`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(` Completed: ${migratedCount} rows migrated`);
|
||||
return migratedCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Main migration function
|
||||
*/
|
||||
async function migrateSqliteToPostgres(): Promise<void> {
|
||||
console.log('Starting SQLite to PostgreSQL migration...\n');
|
||||
|
||||
// Connect to SQLite
|
||||
const sqliteDb = new Database(sqliteConfig.filename);
|
||||
console.log('Connected to SQLite');
|
||||
|
||||
// Connect to PostgreSQL
|
||||
const pgClient = new Client(postgresConfig);
|
||||
await pgClient.connect();
|
||||
console.log('Connected to PostgreSQL');
|
||||
|
||||
try {
|
||||
// Get all tables
|
||||
const tables = getSqliteTables(sqliteDb);
|
||||
console.log(`\nFound ${tables.length} tables to migrate\n`);
|
||||
|
||||
let totalMigrated = 0;
|
||||
|
||||
// Migrate each table
|
||||
for (const tableName of tables) {
|
||||
try {
|
||||
const columns = getSqliteTableColumns(sqliteDb, tableName);
|
||||
|
||||
// Create table in PostgreSQL
|
||||
await createPostgresTable(pgClient, tableName, columns);
|
||||
|
||||
// Migrate data
|
||||
const migrated = await migrateTableData(sqliteDb, pgClient, tableName, columns);
|
||||
totalMigrated += migrated;
|
||||
|
||||
console.log('');
|
||||
} catch (error) {
|
||||
console.error(`Error migrating table ${tableName}:`, error);
|
||||
if (logConfig.verbose) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('=================================');
|
||||
console.log('Migration completed successfully!');
|
||||
console.log(`Total rows migrated: ${totalMigrated}`);
|
||||
console.log(`Source: ${sqliteConfig.filename}`);
|
||||
console.log(`Target: PostgreSQL`);
|
||||
console.log('=================================');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Migration failed:', error);
|
||||
throw error;
|
||||
} finally {
|
||||
sqliteDb.close();
|
||||
await pgClient.end();
|
||||
}
|
||||
}
|
||||
|
||||
// Run migration if called directly
|
||||
if (require.main === module) {
|
||||
migrateSqliteToPostgres().catch(console.error);
|
||||
}
|
||||
|
||||
export { migrateSqliteToPostgres };
|
||||
18
packages/migrator/tsconfig.json
Normal file
18
packages/migrator/tsconfig.json
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": ".",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src/**/*", "index.ts"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
183
progress.md
183
progress.md
|
|
@ -1,183 +0,0 @@
|
|||
# Progress Summary - Meat Farmer Monorepo
|
||||
|
||||
## User UI Development Progress
|
||||
|
||||
### Profile Image & User Details System
|
||||
|
||||
#### Database Schema Updates
|
||||
- **Added `user_details` table** with fields: bio, dateOfBirth, gender, occupation, profileImage
|
||||
- **Established one-to-one relationship** between users and user_details tables
|
||||
- **Updated relations** in schema for proper data fetching
|
||||
|
||||
#### Backend API Enhancements
|
||||
- **Updated auth controller** (`login`, `register`, `getProfile`) to query userDetails and return complete user information
|
||||
- **Added `updateProfile` API** with comprehensive validation and transaction-based updates
|
||||
- **Implemented signed URLs** for secure profile image access (3-day expiration)
|
||||
- **Enhanced tRPC `getSelfData`** to include all user details with signed URLs
|
||||
|
||||
#### Frontend Auth System
|
||||
- **Extended AuthContext** with `userDetails` state and `updateUserDetails` function
|
||||
- **Modified tRPC queries** for fresh data on every app startup (no caching)
|
||||
- **Added `useUserDetails` hook** for accessing detailed user information
|
||||
- **Updated login/register flows** to populate complete user data
|
||||
|
||||
### Edit Profile Functionality
|
||||
|
||||
#### Page Implementation
|
||||
- **Created edit-profile page** with pre-populated form values
|
||||
- **Conditional form rendering** - password fields hidden in edit mode
|
||||
- **Profile image upload** support with existing image display
|
||||
- **Form validation** adjusted for edit vs registration modes
|
||||
|
||||
#### API Integration
|
||||
- **Added `useUpdateProfile` hook** using React Query for profile updates
|
||||
- **Multipart form data handling** for profile image uploads
|
||||
- **Error handling and loading states** with proper user feedback
|
||||
- **Context synchronization** after successful profile updates
|
||||
|
||||
### UI/UX Improvements
|
||||
|
||||
#### Drawer Layout Enhancements
|
||||
- **Profile image display** in drawer header with fallback to person icon
|
||||
- **User details integration** showing name and mobile from userDetails
|
||||
- **Circular avatar styling** for profile images
|
||||
|
||||
#### Me Page Redesign
|
||||
- **2x2 grid layout** replacing vertical button list
|
||||
- **MaterialIcons integration** with relevant icons for each section:
|
||||
- Orders: `shopping-bag` (blue)
|
||||
- Complaints: `report-problem` (green)
|
||||
- Coupons: `local-offer` (purple)
|
||||
- Profile: `person` (orange)
|
||||
- **Enhanced styling** with rounded corners, shadows, and better typography
|
||||
- **Responsive design** with proper spacing and touch targets
|
||||
|
||||
### Registration Form Updates
|
||||
- **Edit mode support** with `isEdit` prop
|
||||
- **Conditional field rendering** (passwords/terms hidden in edit mode)
|
||||
- **Initial values support** for pre-populating form data
|
||||
- **Profile image handling** for both new uploads and existing images
|
||||
|
||||
## Files Modified
|
||||
|
||||
### Backend
|
||||
- `apps/backend/src/db/schema.ts` - Added user_details table, vendor_snippets table, and relations definitions
|
||||
- `apps/backend/src/uv-apis/auth.controller.ts` - Enhanced auth APIs with userDetails and signed URLs
|
||||
- `apps/backend/src/uv-apis/auth.router.ts` - Added update profile route
|
||||
- `apps/backend/src/trpc/user-apis/user.ts` - Updated getSelfData with userDetails
|
||||
- `apps/backend/src/trpc/admin-apis/vendor-snippets.ts` - Complete CRUD API for vendor snippets management
|
||||
|
||||
### Frontend
|
||||
- `apps/user-ui/src/types/auth.ts` - Added UserDetails interface and updated AuthState
|
||||
- `apps/user-ui/src/contexts/AuthContext.tsx` - Enhanced with userDetails state and hooks
|
||||
- `apps/user-ui/src/api-hooks/auth.api.ts` - Added updateProfile API and hook
|
||||
- `apps/user-ui/components/registration-form.tsx` - Added edit mode support
|
||||
- `apps/user-ui/app/(drawer)/edit-profile/index.tsx` - New edit profile page
|
||||
- `apps/user-ui/app/(drawer)/_layout.tsx` - Updated drawer with profile image
|
||||
- `apps/user-ui/app/(drawer)/me/index.tsx` - Redesigned with 2x2 grid and icons
|
||||
|
||||
### Admin UI (New Vendor Snippets Feature)
|
||||
- `apps/admin-ui/app/(drawer)/vendor-snippets/index.tsx` - Main vendor snippets management page
|
||||
- `apps/admin-ui/app/(drawer)/_layout.tsx` - Added vendor snippets to drawer navigation
|
||||
- `apps/admin-ui/components/VendorSnippetForm.tsx` - Create/edit form with validation
|
||||
- `apps/admin-ui/components/SnippetOrdersView.tsx` - Orders viewing component with matching highlights
|
||||
- `apps/admin-ui/src/api-hooks/vendor-snippets.api.ts` - tRPC hooks for vendor snippets operations
|
||||
- `apps/admin-ui/src/trpc-client.ts` - Updated imports for tRPC client usage
|
||||
|
||||
## Key Features Implemented
|
||||
|
||||
### User UI Features
|
||||
✅ **Complete user profile system** with detailed information storage
|
||||
✅ **Secure image handling** with signed URLs and S3 integration
|
||||
✅ **Edit profile functionality** with pre-populated forms and validation
|
||||
✅ **Beautiful UI components** with icons and modern design patterns
|
||||
✅ **Fresh data fetching** on app startup with no caching
|
||||
✅ **Transaction-safe updates** with proper error handling
|
||||
✅ **Responsive grid layouts** optimized for mobile experience
|
||||
|
||||
### Admin UI Features (Vendor Snippets)
|
||||
✅ **Complete vendor snippets management system** with full CRUD operations
|
||||
✅ **Advanced order matching logic** that finds orders by slot and product criteria
|
||||
✅ **Interactive forms** with slot/product selection and validation
|
||||
✅ **Orders viewing interface** with product matching highlights and statistics
|
||||
✅ **Automatic data refresh** using focus callbacks for fresh data
|
||||
✅ **Proper relations handling** in Drizzle ORM with foreign key relationships
|
||||
✅ **Error handling and loading states** throughout the user journey
|
||||
✅ **Navigation integration** with drawer menu and proper routing
|
||||
|
||||
## Admin UI Changes
|
||||
|
||||
### Vendor Snippets Management System
|
||||
|
||||
#### Database Schema Updates
|
||||
- **Added `vendor_snippets` table** with fields: id, snippetCode, slotId, productIds, validTill, createdAt
|
||||
- **Established foreign key relationship** between vendorSnippets and deliverySlotInfo tables
|
||||
- **Added relations definition** (`vendorSnippetsRelations`) for proper Drizzle ORM queries
|
||||
- **Array field support** for storing multiple product IDs per snippet
|
||||
|
||||
#### Backend API Implementation
|
||||
- **Complete CRUD operations** for vendor snippets:
|
||||
- `create`: Validates slot/product existence, prevents duplicate codes
|
||||
- `getAll`: Returns snippets with slot relations, ordered by creation date
|
||||
- `getById`: Fetches individual snippet with slot details
|
||||
- `update`: Partial updates with validation and uniqueness checks
|
||||
- `delete`: Soft delete by setting expiry to current time
|
||||
- **`getOrdersBySnippet` API**: Advanced order matching logic that:
|
||||
- Finds orders with matching delivery slots
|
||||
- Filters orders containing at least one snippet product
|
||||
- Returns formatted order data with product matching highlights
|
||||
- Includes customer details, pricing, and delivery information
|
||||
|
||||
#### Admin UI Implementation
|
||||
- **Vendor Snippets List Page**: Complete management interface with:
|
||||
- Snippet cards showing code, slot info, product count, expiry dates
|
||||
- Action buttons for View Orders, Edit, and Delete operations
|
||||
- Empty state with call-to-action for first snippet creation
|
||||
- Loading states and error handling
|
||||
- **Create/Edit Forms**: Comprehensive form components using:
|
||||
- BottomDropdown for slot selection (replacing custom dropdowns)
|
||||
- MultiSelectDropdown for product selection with search
|
||||
- DatePicker for expiry date management
|
||||
- Form validation with real-time error feedback
|
||||
- Auto-generated snippet codes for new entries
|
||||
|
||||
#### Orders Viewing System
|
||||
- **SnippetOrdersView Component**: Dedicated screen for viewing matched orders:
|
||||
- Order cards with customer details, amounts, and delivery slots
|
||||
- Product lists with matching highlights (⭐ indicators)
|
||||
- Summary statistics (total orders, revenue)
|
||||
- Responsive design with proper spacing and typography
|
||||
|
||||
#### Navigation & UX Enhancements
|
||||
- **Drawer Integration**: Added "Vendor Snippets" to admin navigation menu
|
||||
- **Focus-based Refetching**: Implemented `useFocusCallback` for automatic data refresh when returning to the screen
|
||||
- **Error Handling**: Fixed tRPC client vs hooks usage (`trpcClient` for direct queries)
|
||||
- **Loading States**: Proper loading indicators and user feedback throughout the flow
|
||||
|
||||
#### Technical Improvements
|
||||
- **Relations Fix**: Resolved Drizzle ORM error by adding missing relations definition
|
||||
- **API Client Usage**: Corrected tRPC usage patterns (hooks vs direct client)
|
||||
- **Type Safety**: Proper TypeScript interfaces and error handling
|
||||
- **Performance**: Efficient queries with proper indexing and filtering
|
||||
|
||||
### Previous Admin UI Changes
|
||||
|
||||
#### Slot Selection Centralization
|
||||
- **Moved slot dropdown** from individual pages to Manage Orders hub page
|
||||
- **Updated navigation** with slotId query parameters
|
||||
- **Streamlined child pages** with URL param reading
|
||||
|
||||
#### UI Cleanup & Improvements
|
||||
- **Removed redundant elements** from drawer navigation
|
||||
- **Compacted order displays** for better space utilization
|
||||
- **Enhanced delivery sequences** layout
|
||||
|
||||
## Important Notes
|
||||
- **Do not run build, compile, or migration commands** - These should be handled manually by developers
|
||||
- Avoid running `npm run build`, `tsc`, `drizzle-kit generate`, or similar compilation/migration commands
|
||||
- Schema changes should be committed and migrations generated manually
|
||||
- **Signed URLs** are used for secure image access with 3-day expiration
|
||||
- **React Query** handles all API state management with proper loading/error states
|
||||
- **Vendor Snippets**: Relations definitions are critical for Drizzle ORM queries - always define relations for foreign key relationships
|
||||
- **tRPC Usage**: Use `trpc` for React hooks and `trpcClient` for direct API calls outside components
|
||||
- **Focus Callbacks**: Implemented for automatic data refresh when screens regain focus
|
||||
Loading…
Add table
Reference in a new issue