diff --git a/apps/admin-ui/.detoxrc.js b/apps/admin-ui/.detoxrc.js new file mode 100644 index 0000000..35b91ea --- /dev/null +++ b/apps/admin-ui/.detoxrc.js @@ -0,0 +1,83 @@ +/** @type {Detox.DetoxConfig} */ +module.exports = { + testRunner: { + args: { + '$0': 'jest', + config: 'e2e/jest.config.js' + }, + jest: { + setupTimeout: 120000 + } + }, + apps: { + 'ios.debug': { + type: 'ios.app', + binaryPath: 'ios/build/Build/Products/Debug-iphonesimulator/YOUR_APP.app', + build: 'xcodebuild -workspace ios/YOUR_APP.xcworkspace -scheme YOUR_APP -configuration Debug -sdk iphonesimulator -derivedDataPath ios/build' + }, + 'ios.release': { + type: 'ios.app', + binaryPath: 'ios/build/Build/Products/Release-iphonesimulator/YOUR_APP.app', + build: 'xcodebuild -workspace ios/YOUR_APP.xcworkspace -scheme YOUR_APP -configuration Release -sdk iphonesimulator -derivedDataPath ios/build' + }, + 'android.debug': { + type: 'android.apk', + binaryPath: 'android/app/build/outputs/apk/debug/app-debug.apk', + build: 'cd android && ./gradlew assembleDebug assembleAndroidTest -DtestBuildType=debug', + reversePorts: [ + 8081 + ] + }, + 'android.release': { + type: 'android.apk', + binaryPath: 'android/app/build/outputs/apk/release/app-release.apk', + build: 'cd android && ./gradlew assembleRelease assembleAndroidTest -DtestBuildType=release' + } + }, + devices: { + simulator: { + type: 'ios.simulator', + device: { + type: 'iPhone 15' + } + }, + attached: { + type: 'android.attached', + device: { + adbName: '.*' + } + }, + emulator: { + type: 'android.emulator', + device: { + avdName: 'Pixel_3a_API_30_x86' + } + } + }, + configurations: { + 'ios.sim.debug': { + device: 'simulator', + app: 'ios.debug' + }, + 'ios.sim.release': { + device: 'simulator', + app: 'ios.release' + }, + 'android.att.debug': { + device: 'attached', + app: 'android.debug' + }, + 'android.att.release': { + device: 'attached', + app: 'android.release' + }, + 'android.emu.debug': { + device: 'emulator', + app: 'android.debug' + }, + 'android.emu.release': { + device: 'emulator', + app: 'android.release' + } + } +}; diff --git a/apps/admin-ui/e2e/jest.config.js b/apps/admin-ui/e2e/jest.config.js new file mode 100644 index 0000000..4f98020 --- /dev/null +++ b/apps/admin-ui/e2e/jest.config.js @@ -0,0 +1,12 @@ +/** @type {import('@jest/types').Config.InitialOptions} */ +module.exports = { + rootDir: '..', + testMatch: ['/e2e/**/*.test.js'], + testTimeout: 120000, + maxWorkers: 1, + globalSetup: 'detox/runners/jest/globalSetup', + globalTeardown: 'detox/runners/jest/globalTeardown', + reporters: ['detox/runners/jest/reporter'], + testEnvironment: 'detox/runners/jest/testEnvironment', + verbose: true, +}; diff --git a/apps/admin-ui/e2e/starter.test.js b/apps/admin-ui/e2e/starter.test.js new file mode 100644 index 0000000..054c2a1 --- /dev/null +++ b/apps/admin-ui/e2e/starter.test.js @@ -0,0 +1,23 @@ +describe('Example', () => { + beforeAll(async () => { + await device.launchApp(); + }); + + beforeEach(async () => { + await device.reloadReactNative(); + }); + + it('should have welcome screen', async () => { + await expect(element(by.id('welcome'))).toBeVisible(); + }); + + it('should show hello screen after tap', async () => { + await element(by.id('hello_button')).tap(); + await expect(element(by.text('Hello!!!'))).toBeVisible(); + }); + + it('should show world screen after tap', async () => { + await element(by.id('world_button')).tap(); + await expect(element(by.text('World!!!'))).toBeVisible(); + }); +}); diff --git a/apps/backend/.wrangler/state/v3/do/freshyo-backend-CacheCreator/dfa6f17a813eaf9ad999935788399d71cfee3694a0a726e6d79c51fb1e78afcd.sqlite b/apps/backend/.wrangler/state/v3/do/freshyo-backend-CacheCreator/dfa6f17a813eaf9ad999935788399d71cfee3694a0a726e6d79c51fb1e78afcd.sqlite index 6f21687..e8c9617 100644 Binary files a/apps/backend/.wrangler/state/v3/do/freshyo-backend-CacheCreator/dfa6f17a813eaf9ad999935788399d71cfee3694a0a726e6d79c51fb1e78afcd.sqlite and b/apps/backend/.wrangler/state/v3/do/freshyo-backend-CacheCreator/dfa6f17a813eaf9ad999935788399d71cfee3694a0a726e6d79c51fb1e78afcd.sqlite differ diff --git a/apps/backend/src/jobs/cache-creator.ts b/apps/backend/src/jobs/cache-creator.ts index 9a954af..9ba4bce 100644 --- a/apps/backend/src/jobs/cache-creator.ts +++ b/apps/backend/src/jobs/cache-creator.ts @@ -4,7 +4,7 @@ import { ensureWorkerInit } from '@/src/lib/worker-init' const LAST_TRIGGER_KEY = 'lastTrigger' const ALARM_DELAY_MINUTES = 0.5 -// const ALARM_DELAY_MINUTES = 3 +// const ALARM_DELAY_MINUTES = 0.1 export class CacheCreator { private state: any @@ -53,13 +53,16 @@ export class CacheCreator { async alarm(): Promise { ensureWorkerInit(this.env) + console.log('from the shceduler') const lastTrigger = await this.state.storage.get(LAST_TRIGGER_KEY) if (!lastTrigger) { return } const threshold = dayjs().subtract(ALARM_DELAY_MINUTES, 'minute') - if (dayjs(lastTrigger).isBefore(threshold)) { + const isQualify = dayjs(lastTrigger).isBefore(threshold); + console.log({isQualify, threshold, curr: dayjs(lastTrigger)}) + if (isQualify) { await initializeAllStores() } } diff --git a/apps/backend/src/lib/cloud_cache.ts b/apps/backend/src/lib/cloud_cache.ts index 33c598a..7010c50 100644 --- a/apps/backend/src/lib/cloud_cache.ts +++ b/apps/backend/src/lib/cloud_cache.ts @@ -88,11 +88,15 @@ async function createProductsFileInternal(version: number): Promise { const productsData = await scaffoldProducts() const jsonContent = JSON.stringify(productsData, null, 2) const buffer = Buffer.from(jsonContent, 'utf-8') + const filePath = `${getApiCacheKey()}/${buildCachePath(CACHE_FILENAMES.products, version)}` + + console.log(filePath) return await imageUploadS3( buffer, 'application/json', - `${getApiCacheKey()}/${buildCachePath(CACHE_FILENAMES.products, version)}` - ) + filePath + ) + } async function createEssentialConstsFileInternal(version: number): Promise { diff --git a/apps/backend/src/lib/disk-persisted-set.ts b/apps/backend/src/lib/disk-persisted-set.ts index 5f968f1..e0fccec 100644 --- a/apps/backend/src/lib/disk-persisted-set.ts +++ b/apps/backend/src/lib/disk-persisted-set.ts @@ -1,79 +1,79 @@ -import fs from "fs"; -import path from "path"; - -export class DiskPersistedSet { - private set: Set; - private readonly filePath: string; - private dirty = false; - - constructor(filePath: string = "./persister") { - this.filePath = path.resolve(filePath); - - // ✅ Ensure file exists - if (!fs.existsSync(this.filePath)) { - fs.writeFileSync(this.filePath, "", "utf8"); - } - - // ✅ Load existing values from file - const contents = fs.readFileSync(this.filePath, "utf8"); - this.set = new Set( - contents.split("\n").map(x => x.trim()).filter(x => x.length > 0) - ); - - this.registerExitHandlers(); - } - - private persist() { - if (!this.dirty) return; - fs.writeFileSync(this.filePath, Array.from(this.set).join("\n"), "utf8"); - this.dirty = false; - } - - private markDirty() { - this.dirty = true; - } - - add(value: string): void { - if (!this.set.has(value)) { - this.set.add(value); - this.markDirty(); - this.persist(); - } - } - - delete(value: string): void { - if (this.set.delete(value)) { - this.markDirty(); - this.persist(); - } - } - - has(value: string): boolean { - return this.set.has(value); - } - - values(): string[] { - return Array.from(this.set); - } - - clear(): void { - if (this.set.size > 0) { - this.set.clear(); - this.markDirty(); - this.persist(); - } - } - - private registerExitHandlers() { - const flush = () => this.persist(); - - process.on("exit", flush); - process.on("SIGINT", () => { flush(); process.exit(); }); - process.on("SIGTERM", () => { flush(); process.exit(); }); - process.on("uncaughtException", (err) => { - console.error("Uncaught exception. Flushing DiskPersistedSet:", err); - flush(); - process.exit(1); - }); - } -} +// import fs from "fs"; +// import path from "path"; +// +// export class DiskPersistedSet { +// private set: Set; +// private readonly filePath: string; +// private dirty = false; +// +// constructor(filePath: string = "./persister") { +// this.filePath = path.resolve(filePath); +// +// // ✅ Ensure file exists +// if (!fs.existsSync(this.filePath)) { +// fs.writeFileSync(this.filePath, "", "utf8"); +// } +// +// // ✅ Load existing values from file +// const contents = fs.readFileSync(this.filePath, "utf8"); +// this.set = new Set( +// contents.split("\n").map(x => x.trim()).filter(x => x.length > 0) +// ); +// +// this.registerExitHandlers(); +// } +// +// private persist() { +// if (!this.dirty) return; +// fs.writeFileSync(this.filePath, Array.from(this.set).join("\n"), "utf8"); +// this.dirty = false; +// } +// +// private markDirty() { +// this.dirty = true; +// } +// +// add(value: string): void { +// if (!this.set.has(value)) { +// this.set.add(value); +// this.markDirty(); +// this.persist(); +// } +// } +// +// delete(value: string): void { +// if (this.set.delete(value)) { +// this.markDirty(); +// this.persist(); +// } +// } +// +// has(value: string): boolean { +// return this.set.has(value); +// } +// +// values(): string[] { +// return Array.from(this.set); +// } +// +// clear(): void { +// if (this.set.size > 0) { +// this.set.clear(); +// this.markDirty(); +// this.persist(); +// } +// } +// +// private registerExitHandlers() { +// const flush = () => this.persist(); +// +// process.on("exit", flush); +// process.on("SIGINT", () => { flush(); process.exit(); }); +// process.on("SIGTERM", () => { flush(); process.exit(); }); +// process.on("uncaughtException", (err) => { +// console.error("Uncaught exception. Flushing DiskPersistedSet:", err); +// flush(); +// process.exit(1); +// }); +// } +// } diff --git a/apps/backend/src/lib/s3-client.ts b/apps/backend/src/lib/s3-client.ts old mode 100755 new mode 100644 index 571b111..2038dc2 --- a/apps/backend/src/lib/s3-client.ts +++ b/apps/backend/src/lib/s3-client.ts @@ -1,8 +1,5 @@ -// import { s3A, awsBucketName, awsRegion, awsSecretAccessKey } from "@/src/lib/env-exporter" import type { Buffer } from 'buffer' -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" // Disabled for Workers compatibility +import { AwsClient } from 'aws4fetch' import { claimUploadUrlStatus, createUploadUrlStatus } from '@/src/dbService' import { getS3AccessKeyId, @@ -11,74 +8,81 @@ import { getS3SecretAccessKey, getS3BucketName, getAssetsDomain, -} from "@/src/lib/env-exporter" +} from '@/src/lib/env-exporter' -let s3Client: S3Client | null = null -let s3ClientKey = '' +let awsClient: AwsClient | null = null +let awsClientKey = '' -const getS3Client = () => { +const getAwsClient = () => { const region = getS3Region() const endpoint = getS3Url() const accessKeyId = getS3AccessKeyId() const secretAccessKey = getS3SecretAccessKey() const nextKey = `${region}|${endpoint}|${accessKeyId}|${secretAccessKey}` - if (!s3Client || nextKey !== s3ClientKey) { - s3ClientKey = nextKey - s3Client = new S3Client({ + if (!awsClient || nextKey !== awsClientKey) { + awsClientKey = nextKey + awsClient = new AwsClient({ + accessKeyId, + secretAccessKey, region, - endpoint, - forcePathStyle: true, - credentials: { - accessKeyId, - secretAccessKey, - }, + service: 's3', }) } - return s3Client + return awsClient } -export const imageUploadS3 = async(body: Buffer, type: string, key:string) => { - // const key = `${category}/${Date.now()}` - const s3BucketName = getS3BucketName() - const s3Client = getS3Client() - const command = new PutObjectCommand({ - Bucket: s3BucketName, - Key: key, - Body: body, - ContentType: type, +const buildObjectUrl = (bucket: string, key: string) => { + const endpoint = getS3Url() + const normalizedEndpoint = endpoint.endsWith('/') + ? endpoint.slice(0, -1) + : endpoint + const normalizedKey = key.replace(/^\/+/, '') + return `${normalizedEndpoint}/${bucket}/${normalizedKey}` +} + +export const imageUploadS3 = async(body: Buffer, type: string, key: string) => { + const client = getAwsClient() + const url = buildObjectUrl(getS3BucketName(), key) + const resp = await client.fetch(url, { + method: 'PUT', + headers: { + 'Content-Type': type, + }, + body, }) - const resp = await s3Client.send(command) - + if (!resp.ok) { + const responseBody = await resp.text().catch(() => '') + throw new Error(`Failed to upload image: ${resp.status} ${responseBody}`) + } const imageUrl = `${key}` - return imageUrl; + return imageUrl } - // export async function deleteImageUtil(...keys:string[]):Promise; -export async function deleteImageUtil({bucket = getS3BucketName(), keys}:{bucket?:string, keys: string[]}) { - +export async function deleteImageUtil({bucket = getS3BucketName(), keys}:{bucket?: string, keys: string[]}) { if (keys.length === 0) { - return true; + return true } try { - const s3Client = getS3Client() + const client = getAwsClient() await Promise.all( - keys.map((key) => { - const deleteCommand = new DeleteObjectCommand({ - Bucket: bucket, - Key: key, - }) - return s3Client.send(deleteCommand) + keys.map(async (key) => { + const url = buildObjectUrl(bucket, key) + const resp = await client.fetch(url, { method: 'DELETE' }) + if (!resp.ok && resp.status !== 404) { + const body = await resp.text().catch(() => '') + throw new Error(`Failed to delete image: ${resp.status} ${body}`) + } }) ) return true } catch (error) { - console.error("Error deleting image:", error) - throw new Error("Failed to delete image") + console.error('Error deleting image:', error) + throw new Error('Failed to delete image') } } @@ -87,19 +91,18 @@ export function scaffoldAssetUrl(input: (string | null)[]): string[] export function scaffoldAssetUrl(input: string | null | (string | null)[]): string | string[] { const assetsDomain = getAssetsDomain() if (Array.isArray(input)) { - return input.map(key => scaffoldAssetUrl(key) as string); + return input.map(key => scaffoldAssetUrl(key) as string) } if (!input) { - return ''; + return '' } - const normalizedKey = input.replace(/^\/+/, ''); + const normalizedKey = input.replace(/^\/+/, '') const domain = assetsDomain.endsWith('/') ? assetsDomain.slice(0, -1) - : assetsDomain; - return `${domain}/${normalizedKey}`; + : assetsDomain + return `${domain}/${normalizedKey}` } - /** * Generate a signed URL from an S3 URL * @param s3Url The full S3 URL (e.g., https://bucket-name.s3.region.amazonaws.com/path/to/object) @@ -108,34 +111,23 @@ export function scaffoldAssetUrl(input: string | null | (string | null)[]): stri */ export async function generateSignedUrlFromS3Url(s3UrlRaw: string|null, expiresIn: number = 259200): Promise { if (!s3UrlRaw) { - return ''; + return '' } const s3Url = s3UrlRaw try { - // 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({ - Bucket: getS3BucketName(), - Key: s3Url, - }); - - // Generate the signed URL - const signedUrl = await getSignedUrl(getS3Client(), command, { expiresIn }); - - // Cache disabled for Workers compatibility - // signedUrlCache.set(s3Url, signedUrl, (expiresIn * 1000) - 60000); - - return signedUrl; + const client = getAwsClient() + const url = buildObjectUrl(getS3BucketName(), s3Url) + const signedRequest = await client.sign(url, { + method: 'GET', + signQuery: true, + expires: expiresIn, + }) + return signedRequest.url } catch (error) { - console.error("Error generating signed URL:", error); - throw new Error("Failed to generate signed URL"); + console.error('Error generating signed URL:', error) + throw new Error('Failed to generate signed URL') } } @@ -147,7 +139,7 @@ export async function generateSignedUrlFromS3Url(s3UrlRaw: string|null, expiresI export function getOriginalUrlFromSignedUrl(signedUrl: string|null): string|null { // 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; + return null } /** @@ -158,44 +150,43 @@ export function getOriginalUrlFromSignedUrl(signedUrl: string|null): string|null */ export async function generateSignedUrlsFromS3Urls(s3Urls: (string|null)[], expiresIn: number = 259200): Promise { if (!s3Urls || !s3Urls.length) { - return []; + return [] } try { - // Process URLs in parallel for better performance const signedUrls = await Promise.all( s3Urls.map(url => generateSignedUrlFromS3Url(url, expiresIn).catch(() => '')) - ); + ) - return signedUrls; + return signedUrls } catch (error) { - console.error("Error generating multiple signed URLs:", error); - // Return an array of empty strings with the same length as input - return s3Urls.map(() => ''); + console.error('Error generating multiple signed URLs:', error) + return s3Urls.map(() => '') } } export async function generateUploadUrl(key: string, mimeType: string, expiresIn: number = 180): Promise { try { - // Insert record into upload_url_status await createUploadUrlStatus(key) - // Generate signed upload URL - const command = new PutObjectCommand({ - Bucket: getS3BucketName(), - Key: key, - ContentType: mimeType, - }); + const client = getAwsClient() + const url = buildObjectUrl(getS3BucketName(), key) + const signedRequest = await client.sign(url, { + method: 'PUT', + signQuery: true, + expires: expiresIn, + headers: { + 'Content-Type': mimeType, + }, + }) - const signedUrl = await getSignedUrl(getS3Client(), command, { expiresIn }); - return signedUrl; + return signedRequest.url } catch (error) { - console.error('Error generating upload URL:', error); - throw new Error('Failed to generate upload URL'); + console.error('Error generating upload URL:', error) + throw new Error('Failed to generate upload URL') } } - // export function extractKeyFromPresignedUrl(url:string) { // const u = new URL(url); // const rawKey = u.pathname.replace(/^\/+/, ""); // remove leading slash @@ -204,27 +195,27 @@ export async function generateUploadUrl(key: string, mimeType: string, expiresIn // New function (excludes bucket name) export function extractKeyFromPresignedUrl(url: string): string { - const u = new URL(url); - const rawKey = u.pathname.replace(/^\/+/, ""); // remove leading slash - const decodedKey = decodeURIComponent(rawKey); + const u = new URL(url) + const rawKey = u.pathname.replace(/^\/+/, '') // remove leading slash + const decodedKey = decodeURIComponent(rawKey) // Remove bucket prefix - const parts = decodedKey.split('/'); - parts.shift(); // Remove bucket name - return parts.join('/'); + const parts = decodedKey.split('/') + parts.shift() // Remove bucket name + return parts.join('/') } export async function claimUploadUrl(url: string): Promise { try { - const semiKey = extractKeyFromPresignedUrl(url); + const semiKey = extractKeyFromPresignedUrl(url) // Update status to 'claimed' if currently 'pending' const updated = await claimUploadUrlStatus(semiKey) if (!updated) { - throw new Error('Upload URL not found or already claimed'); + throw new Error('Upload URL not found or already claimed') } } catch (error) { - console.error('Error claiming upload URL:', error); - throw new Error('Failed to claim upload URL'); + console.error('Error claiming upload URL:', error) + throw new Error('Failed to claim upload URL') } } diff --git a/apps/backend/src/lib/s3-client.ts.txt b/apps/backend/src/lib/s3-client.ts.txt new file mode 100644 index 0000000..5b29e3c --- /dev/null +++ b/apps/backend/src/lib/s3-client.ts.txt @@ -0,0 +1,244 @@ +// import { s3A, awsBucketName, awsRegion, awsSecretAccessKey } from "@/src/lib/env-exporter" +import type { Buffer } from 'buffer' +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" // Disabled for Workers compatibility +import { claimUploadUrlStatus, createUploadUrlStatus } from '@/src/dbService' +import { + getS3AccessKeyId, + getS3Region, + getS3Url, + getS3SecretAccessKey, + getS3BucketName, + getAssetsDomain, +} from "@/src/lib/env-exporter" + +let s3Client: S3Client | null = null +let s3ClientKey = '' + +const getS3Client = () => { + const region = getS3Region() + const endpoint = getS3Url() + const accessKeyId = getS3AccessKeyId() + const secretAccessKey = getS3SecretAccessKey() + const nextKey = `${region}|${endpoint}|${accessKeyId}|${secretAccessKey}` + + if (!s3Client || nextKey !== s3ClientKey) { + s3ClientKey = nextKey + s3Client = new S3Client({ + region, + endpoint, + forcePathStyle: true, + credentials: { + accessKeyId, + secretAccessKey, + }, + }) + } + + return s3Client +} + +// export const imageUploadS3 = async(body: Buffer, type: string, key:string) => { +// // const key = `${category}/${Date.now()}` +// const s3BucketName = getS3BucketName() +// const s3Client = getS3Client() +// const command = new PutObjectCommand({ +// Bucket: s3BucketName, +// Key: key, +// Body: body, +// ContentType: type, +// }) +// const resp = await s3Client.send(command) +// +// const imageUrl = `${key}` +// return imageUrl; +// } + +export const imageUploadS3 = async(body: Buffer, type: string, key:string) => { + const env = (globalThis as any).ENV || {} + if (!env.MY_BUCKET) { + throw new Error('MY_BUCKET binding not found in runtime env') + } + await env.MY_BUCKET.put(key, body, { + httpMetadata: { + contentType: type, + }, + }) + const imageUrl = `${key}` + return imageUrl +} + + +// export async function deleteImageUtil(...keys:string[]):Promise; + +export async function deleteImageUtil({bucket = getS3BucketName(), keys}:{bucket?:string, keys: string[]}) { + + if (keys.length === 0) { + return true; + } + try { + const s3Client = getS3Client() + await Promise.all( + keys.map((key) => { + const deleteCommand = new DeleteObjectCommand({ + Bucket: bucket, + Key: key, + }) + return s3Client.send(deleteCommand) + }) + ) + return true + } + catch (error) { + console.error("Error deleting image:", error) + throw new Error("Failed to delete image") + } +} + +export function scaffoldAssetUrl(input: string | null): string +export function scaffoldAssetUrl(input: (string | null)[]): string[] +export function scaffoldAssetUrl(input: string | null | (string | null)[]): string | string[] { + const assetsDomain = getAssetsDomain() + if (Array.isArray(input)) { + return input.map(key => scaffoldAssetUrl(key) as string); + } + if (!input) { + return ''; + } + const normalizedKey = input.replace(/^\/+/, ''); + const domain = assetsDomain.endsWith('/') + ? assetsDomain.slice(0, -1) + : assetsDomain; + return `${domain}/${normalizedKey}`; +} + + +/** + * Generate a signed URL from an S3 URL + * @param s3Url The full S3 URL (e.g., https://bucket-name.s3.region.amazonaws.com/path/to/object) + * @param expiresIn Expiration time in seconds (default: 259200 seconds = 3 days) + * @returns A pre-signed URL that provides temporary access to the object + */ +export async function generateSignedUrlFromS3Url(s3UrlRaw: string|null, expiresIn: number = 259200): Promise { + if (!s3UrlRaw) { + return ''; + } + + const s3Url = s3UrlRaw + + try { + // 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({ + Bucket: getS3BucketName(), + Key: s3Url, + }); + + // Generate the signed URL + const signedUrl = await getSignedUrl(getS3Client(), command, { expiresIn }); + + // Cache disabled for Workers compatibility + // signedUrlCache.set(s3Url, signedUrl, (expiresIn * 1000) - 60000); + + return signedUrl; + } catch (error) { + console.error("Error generating signed URL:", error); + throw new Error("Failed to generate signed URL"); + } +} + +/** + * Get the original S3 URL from a signed URL + * @param signedUrl The signed URL + * @returns The original S3 URL if found in cache, otherwise null + */ +export function getOriginalUrlFromSignedUrl(signedUrl: string|null): string|null { + // 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; +} + +/** + * Generate signed URLs for multiple S3 URLs + * @param s3Urls Array of S3 URLs or null values + * @param expiresIn Expiration time in seconds (default: 259200 seconds = 3 days) + * @returns Array of signed URLs (empty strings for null/invalid inputs) + */ +export async function generateSignedUrlsFromS3Urls(s3Urls: (string|null)[], expiresIn: number = 259200): Promise { + if (!s3Urls || !s3Urls.length) { + return []; + } + + try { + // Process URLs in parallel for better performance + const signedUrls = await Promise.all( + s3Urls.map(url => generateSignedUrlFromS3Url(url, expiresIn).catch(() => '')) + ); + + return signedUrls; + } catch (error) { + console.error("Error generating multiple signed URLs:", error); + // Return an array of empty strings with the same length as input + return s3Urls.map(() => ''); + } +} + +export async function generateUploadUrl(key: string, mimeType: string, expiresIn: number = 180): Promise { + try { + // Insert record into upload_url_status + await createUploadUrlStatus(key) + + // Generate signed upload URL + const command = new PutObjectCommand({ + Bucket: getS3BucketName(), + Key: key, + ContentType: mimeType, + }); + + const signedUrl = await getSignedUrl(getS3Client(), command, { expiresIn }); + return signedUrl; + } catch (error) { + console.error('Error generating upload URL:', error); + throw new Error('Failed to generate upload URL'); + } +} + + +// export function extractKeyFromPresignedUrl(url:string) { +// const u = new URL(url); +// const rawKey = u.pathname.replace(/^\/+/, ""); // remove leading slash +// return decodeURIComponent(rawKey); +// } + +// New function (excludes bucket name) +export function extractKeyFromPresignedUrl(url: string): string { + const u = new URL(url); + const rawKey = u.pathname.replace(/^\/+/, ""); // remove leading slash + const decodedKey = decodeURIComponent(rawKey); + // Remove bucket prefix + const parts = decodedKey.split('/'); + parts.shift(); // Remove bucket name + return parts.join('/'); +} + +export async function claimUploadUrl(url: string): Promise { + try { + const semiKey = extractKeyFromPresignedUrl(url); + + // Update status to 'claimed' if currently 'pending' + const updated = await claimUploadUrlStatus(semiKey) + + if (!updated) { + throw new Error('Upload URL not found or already claimed'); + } + } catch (error) { + console.error('Error claiming upload URL:', error); + throw new Error('Failed to claim upload URL'); + } +} diff --git a/apps/backend/src/trpc/apis/admin-apis/apis/product.ts b/apps/backend/src/trpc/apis/admin-apis/apis/product.ts index cc464a5..0c3c366 100644 --- a/apps/backend/src/trpc/apis/admin-apis/apis/product.ts +++ b/apps/backend/src/trpc/apis/admin-apis/apis/product.ts @@ -226,7 +226,7 @@ export const productRouter = router({ price: z.number().positive('Price must be positive'), marketPrice: z.number().optional(), incrementStep: z.number().optional().default(1), - productQuantity: z.number().optional().default(1), + productQuantity: z.union([z.number(), z.string()]).optional().default(1), isSuspended: z.boolean().optional().default(false), isFlashAvailable: z.boolean().optional().default(false), flashPrice: z.number().optional(), @@ -262,7 +262,7 @@ export const productRouter = router({ price: price.toString(), marketPrice: marketPrice?.toString(), incrementStep, - productQuantity, + productQuantity: productQuantity as any, isSuspended, isFlashAvailable, flashPrice: flashPrice?.toString(), @@ -302,7 +302,7 @@ export const productRouter = router({ price: z.number().positive('Price must be positive'), marketPrice: z.number().optional(), incrementStep: z.number().optional().default(1), - productQuantity: z.number().optional().default(1), + productQuantity: z.union([z.number(), z.string()]).optional().default(1), isSuspended: z.boolean().optional().default(false), isFlashAvailable: z.boolean().optional().default(false), flashPrice: z.number().nullable().optional(), @@ -347,7 +347,7 @@ export const productRouter = router({ price: price.toString(), marketPrice: marketPrice?.toString(), incrementStep, - productQuantity, + productQuantity: productQuantity as any, isSuspended, isFlashAvailable, flashPrice: flashPrice?.toString() ?? null, diff --git a/apps/backend/wrangler.dev.toml b/apps/backend/wrangler.dev.toml index c32e16b..101d901 100644 --- a/apps/backend/wrangler.dev.toml +++ b/apps/backend/wrangler.dev.toml @@ -40,6 +40,10 @@ queue = "order-placed-queue-dev" [[queues.consumers]] queue = "order-cancelled-queue-dev" +[[r2_buckets]] +binding = "MY_BUCKET" +bucket_name = "meatfarmer-dev" + [observability] enabled = false head_sampling_rate = 1 diff --git a/apps/backend/wrangler.prod.toml b/apps/backend/wrangler.prod.toml index 37ed9bf..a3db367 100644 --- a/apps/backend/wrangler.prod.toml +++ b/apps/backend/wrangler.prod.toml @@ -40,6 +40,10 @@ queue = "order-placed-queue" [[queues.consumers]] queue = "order-cancelled-queue" +[[r2_buckets]] +binding = "MY_BUCKET" +bucket_name = "meatfarmer" + [observability] enabled = false head_sampling_rate = 1 diff --git a/apps/user-ui/components/CheckoutAddressSelector.tsx b/apps/user-ui/components/CheckoutAddressSelector.tsx index df91838..6a4129e 100644 --- a/apps/user-ui/components/CheckoutAddressSelector.tsx +++ b/apps/user-ui/components/CheckoutAddressSelector.tsx @@ -6,6 +6,7 @@ import AddressForm from '@/src/components/AddressForm'; import LocationAttacher from '@/src/components/LocationAttacher'; import MaterialIcons from '@expo/vector-icons/MaterialIcons'; import { trpc } from '@/src/trpc-client'; +import { useAuth } from '@/src/contexts/AuthContext'; import * as Location from 'expo-location'; interface AddressSelectorProps { @@ -22,7 +23,11 @@ const CheckoutAddressSelector: React.FC = ({ const [locationLoading, setLocationLoading] = useState(false); const queryClient = useQueryClient(); const scrollViewRef = useRef(null); - const { data: addresses } = trpc.user.address.getUserAddresses.useQuery(); + const { isAuthenticated } = useAuth(); + const { data: addresses } = trpc.user.address.getUserAddresses.useQuery( + undefined, + { enabled: isAuthenticated } + ); const updateAddressMutation = trpc.user.address.updateAddress.useMutation({ onSuccess: () => { diff --git a/apps/user-ui/components/cart-page.tsx b/apps/user-ui/components/cart-page.tsx index 01c41a9..16413fc 100644 --- a/apps/user-ui/components/cart-page.tsx +++ b/apps/user-ui/components/cart-page.tsx @@ -80,7 +80,10 @@ export default function CartPage({ isFlashDelivery = false }: CartPageProps) { return desc; }; - const { data: couponsRaw, error: couponsError } = trpc.user.coupon.getEligible.useQuery(); + const { data: couponsRaw, error: couponsError } = trpc.user.coupon.getEligible.useQuery( + undefined, + { enabled: isAuthenticated } + ); const { data: constsData } = useGetEssentialConsts(); const products = useCentralProductStore((state) => state.products); const productsById = useCentralProductStore((state) => state.productsById); diff --git a/apps/user-ui/components/checkout-page.tsx b/apps/user-ui/components/checkout-page.tsx index 84f1d50..f0ec290 100644 --- a/apps/user-ui/components/checkout-page.tsx +++ b/apps/user-ui/components/checkout-page.tsx @@ -6,6 +6,7 @@ import MaterialIcons from '@expo/vector-icons/MaterialIcons'; import { useQueryClient } from '@tanstack/react-query'; import AddressForm from '@/src/components/AddressForm'; import { useAuthenticatedRoute } from '@/hooks/useAuthenticatedRoute'; +import { useAuth } from '@/src/contexts/AuthContext'; import { trpc } from '@/src/trpc-client'; import { useCentralProductStore } from '@/src/store/centralProductStore'; @@ -24,6 +25,7 @@ const CheckoutPage: React.FC = ({ isFlashDelivery = false }) const params = useLocalSearchParams(); const queryClient = useQueryClient(); const router = useRouter(); + const { isAuthenticated } = useAuth(); // Protect checkout route and preserve query params useAuthenticatedRoute({ @@ -34,7 +36,10 @@ const CheckoutPage: React.FC = ({ isFlashDelivery = false }) const cartType: "regular" | "flash" = isFlashDelivery ? "flash" : "regular"; const { data: cartData, refetch: refetchCart } = useGetCart({}, cartType); - const { data: addresses, refetch: refetchAddresses } = trpc.user.address.getUserAddresses.useQuery(); + const { data: addresses, refetch: refetchAddresses } = trpc.user.address.getUserAddresses.useQuery( + undefined, + { enabled: isAuthenticated } + ); const { data: slotsData, refetch: refetchSlots } = trpc.user.slots.getSlots.useQuery(); const { data: constsData } = useGetEssentialConsts(); const products = useCentralProductStore((state) => state.products); @@ -137,7 +142,10 @@ const CheckoutPage: React.FC = ({ isFlashDelivery = false }) 0 ); - const { data: couponsRaw } = trpc.user.coupon.getEligible.useQuery(); + const { data: couponsRaw } = trpc.user.coupon.getEligible.useQuery( + undefined, + { enabled: isAuthenticated } + ); const eligibleCoupons = useMemo(() => { if (!couponsRaw?.data) return []; diff --git a/packages/db_helper_sqlite/src/admin-apis/product.ts b/packages/db_helper_sqlite/src/admin-apis/product.ts index 4d59201..275ebb1 100644 --- a/packages/db_helper_sqlite/src/admin-apis/product.ts +++ b/packages/db_helper_sqlite/src/admin-apis/product.ts @@ -163,13 +163,38 @@ type ProductInfoInsert = InferInsertModel type ProductInfoUpdate = Partial export async function createProduct(input: ProductInfoInsert): Promise { - const [product] = await db.insert(productInfo).values(input).returning() + const productQuantityRaw = (input as any).productQuantity + const productQuantity = typeof productQuantityRaw === 'string' + ? Number(productQuantityRaw) + : productQuantityRaw + + const safeProductQuantity = typeof productQuantity === 'number' && Number.isFinite(productQuantity) + ? productQuantity + : 1 + + const [product] = await db.insert(productInfo).values({ + ...input, + productQuantity: safeProductQuantity, + }).returning() return mapProduct(product) } export async function updateProduct(id: number, updates: ProductInfoUpdate): Promise { + const productQuantityRaw = (updates as any).productQuantity + const productQuantity = typeof productQuantityRaw === 'string' + ? Number(productQuantityRaw) + : productQuantityRaw + const safeUpdates = typeof productQuantityRaw === 'undefined' + ? updates + : { + ...updates, + productQuantity: typeof productQuantity === 'number' && Number.isFinite(productQuantity) + ? productQuantity + : 1, + } + const [product] = await db.update(productInfo) - .set(updates) + .set(safeUpdates) .where(eq(productInfo.id, id)) .returning() if (!product) { diff --git a/packages/ui/index.ts b/packages/ui/index.ts index 562b9b9..7f3aa2c 100755 --- a/packages/ui/index.ts +++ b/packages/ui/index.ts @@ -64,8 +64,8 @@ const isDevMode = Constants.executionEnvironment !== "standalone"; // const BASE_API_URL = API_URL; // const BASE_API_URL = 'http://10.0.2.2:4000'; // const BASE_API_URL = 'http://192.168.100.101:4000'; -// const BASE_API_URL = 'http://192.168.1.5:4000'; - const BASE_API_URL = 'http://192.168.100.109:8787'; +const BASE_API_URL = 'http://192.168.1.5:8787'; + // const BASE_API_URL = 'http://192.168.100.109:8787'; // let BASE_API_URL = "https://raw.freshyo.in"; // let BASE_API_URL = "https://worker.freshyo.in"; // let BASE_API_URL = "https://freshyo.technocracy.ovh";