enh
This commit is contained in:
parent
8ea26f5705
commit
4199ff7d9b
17 changed files with 605 additions and 196 deletions
83
apps/admin-ui/.detoxrc.js
Normal file
83
apps/admin-ui/.detoxrc.js
Normal file
|
|
@ -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'
|
||||
}
|
||||
}
|
||||
};
|
||||
12
apps/admin-ui/e2e/jest.config.js
Normal file
12
apps/admin-ui/e2e/jest.config.js
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
/** @type {import('@jest/types').Config.InitialOptions} */
|
||||
module.exports = {
|
||||
rootDir: '..',
|
||||
testMatch: ['<rootDir>/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,
|
||||
};
|
||||
23
apps/admin-ui/e2e/starter.test.js
Normal file
23
apps/admin-ui/e2e/starter.test.js
Normal file
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
Binary file not shown.
|
|
@ -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<void> {
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -88,11 +88,15 @@ async function createProductsFileInternal(version: number): Promise<string> {
|
|||
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<string> {
|
||||
|
|
|
|||
|
|
@ -1,79 +1,79 @@
|
|||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
export class DiskPersistedSet {
|
||||
private set: Set<string>;
|
||||
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<string>;
|
||||
// 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);
|
||||
// });
|
||||
// }
|
||||
// }
|
||||
|
|
|
|||
189
apps/backend/src/lib/s3-client.ts
Executable file → Normal file
189
apps/backend/src/lib/s3-client.ts
Executable file → Normal file
|
|
@ -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({
|
||||
region,
|
||||
endpoint,
|
||||
forcePathStyle: true,
|
||||
credentials: {
|
||||
if (!awsClient || nextKey !== awsClientKey) {
|
||||
awsClientKey = nextKey
|
||||
awsClient = new AwsClient({
|
||||
accessKeyId,
|
||||
secretAccessKey,
|
||||
},
|
||||
region,
|
||||
service: 's3',
|
||||
})
|
||||
}
|
||||
|
||||
return s3Client
|
||||
return awsClient
|
||||
}
|
||||
|
||||
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 key = `${category}/${Date.now()}`
|
||||
const s3BucketName = getS3BucketName()
|
||||
const s3Client = getS3Client()
|
||||
const command = new PutObjectCommand({
|
||||
Bucket: s3BucketName,
|
||||
Key: key,
|
||||
Body: body,
|
||||
ContentType: type,
|
||||
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)
|
||||
|
||||
const imageUrl = `${key}`
|
||||
return imageUrl;
|
||||
if (!resp.ok) {
|
||||
const responseBody = await resp.text().catch(() => '')
|
||||
throw new Error(`Failed to upload image: ${resp.status} ${responseBody}`)
|
||||
}
|
||||
const imageUrl = `${key}`
|
||||
return imageUrl
|
||||
}
|
||||
|
||||
|
||||
// export async function deleteImageUtil(...keys:string[]):Promise<boolean>;
|
||||
|
||||
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<string> {
|
||||
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<string[]> {
|
||||
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<string> {
|
||||
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<void> {
|
||||
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')
|
||||
}
|
||||
}
|
||||
|
|
|
|||
244
apps/backend/src/lib/s3-client.ts.txt
Normal file
244
apps/backend/src/lib/s3-client.ts.txt
Normal file
|
|
@ -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<boolean>;
|
||||
|
||||
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<string> {
|
||||
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<string[]> {
|
||||
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<string> {
|
||||
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<void> {
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<AddressSelectorProps> = ({
|
|||
const [locationLoading, setLocationLoading] = useState(false);
|
||||
const queryClient = useQueryClient();
|
||||
const scrollViewRef = useRef<ScrollView>(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: () => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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<CheckoutPageProps> = ({ 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<CheckoutPageProps> = ({ 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<CheckoutPageProps> = ({ 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 [];
|
||||
|
|
|
|||
|
|
@ -163,13 +163,38 @@ type ProductInfoInsert = InferInsertModel<typeof productInfo>
|
|||
type ProductInfoUpdate = Partial<ProductInfoInsert>
|
||||
|
||||
export async function createProduct(input: ProductInfoInsert): Promise<AdminProduct> {
|
||||
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<AdminProduct | null> {
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue