diff --git a/apps/admin-ui/app/(drawer)/_layout.tsx b/apps/admin-ui/app/(drawer)/_layout.tsx index 79dd68b..c83471e 100644 --- a/apps/admin-ui/app/(drawer)/_layout.tsx +++ b/apps/admin-ui/app/(drawer)/_layout.tsx @@ -135,6 +135,13 @@ function CustomDrawerContent() { )} /> + router.push("/(drawer)/offers" as any)} + icon={({ color, size }) => ( + + )} + /> logout()} @@ -225,6 +232,7 @@ export default function Layout() { + diff --git a/apps/admin-ui/app/(drawer)/dashboard/index.tsx b/apps/admin-ui/app/(drawer)/dashboard/index.tsx index a3de344..2cf1631 100644 --- a/apps/admin-ui/app/(drawer)/dashboard/index.tsx +++ b/apps/admin-ui/app/(drawer)/dashboard/index.tsx @@ -175,6 +175,16 @@ export default function Dashboard() { category: 'marketing', iconColor: '#F97316', iconBg: '#FFEDD5', + }, + { + title: 'Offers', + icon: 'sell', + description: 'Create and manage product offers', + route: '/(drawer)/offers' as any, + category: 'marketing', + iconColor: '#DC2626', + iconBg: '#FEE2E2', + testID: 'offers-menu-item', }, { title: 'App Constants', diff --git a/apps/admin-ui/app/(drawer)/offers/_layout.tsx b/apps/admin-ui/app/(drawer)/offers/_layout.tsx new file mode 100644 index 0000000..645b8d2 --- /dev/null +++ b/apps/admin-ui/app/(drawer)/offers/_layout.tsx @@ -0,0 +1,11 @@ +import { Stack } from 'expo-router' + +export default function Layout() { + return ( + + + + + + ) +} diff --git a/apps/admin-ui/app/(drawer)/offers/add.tsx b/apps/admin-ui/app/(drawer)/offers/add.tsx new file mode 100644 index 0000000..cd16d8c --- /dev/null +++ b/apps/admin-ui/app/(drawer)/offers/add.tsx @@ -0,0 +1,21 @@ +import React from 'react' +import { AppContainer } from 'common-ui' +import OfferForm from '@/components/OfferForm' +import { useRouter } from 'expo-router' +import { trpc } from '@/src/trpc-client' + +export default function AddOffer() { + const router = useRouter() + const { refetch } = trpc.admin.offers.getAll.useQuery() + + const handleOfferAdded = () => { + refetch() + router.back() + } + + return ( + + + + ) +} diff --git a/apps/admin-ui/app/(drawer)/offers/edit/[id].tsx b/apps/admin-ui/app/(drawer)/offers/edit/[id].tsx new file mode 100644 index 0000000..8861b0c --- /dev/null +++ b/apps/admin-ui/app/(drawer)/offers/edit/[id].tsx @@ -0,0 +1,67 @@ +import React from 'react' +import { View, Text, ActivityIndicator } from 'react-native' +import { AppContainer, tw } from 'common-ui' +import OfferForm from '@/components/OfferForm' +import { useRouter, useLocalSearchParams } from 'expo-router' +import { trpc } from '@/src/trpc-client' + +export default function EditOffer() { + const router = useRouter() + const { id } = useLocalSearchParams() + const offerId = parseInt(id as string) + + const { data: offerData, isLoading } = trpc.admin.offers.getById.useQuery( + { id: offerId }, + { enabled: !!offerId } + ) + + const handleOfferUpdated = () => { + router.back() + } + + if (isLoading) { + return ( + + + + Loading offer... + + + ) + } + + if (!offerData?.offer) { + return ( + + + Offer not found + + + ) + } + + const offer = offerData.offer + + return ( + + ({ + productId: item.productId, + quantity: item.quantity, + product: item.product, + }))} + onOfferAdded={handleOfferUpdated} + /> + + ) +} diff --git a/apps/admin-ui/app/(drawer)/offers/index.tsx b/apps/admin-ui/app/(drawer)/offers/index.tsx new file mode 100644 index 0000000..a19c6c9 --- /dev/null +++ b/apps/admin-ui/app/(drawer)/offers/index.tsx @@ -0,0 +1,146 @@ +import React from 'react' +import { View, TouchableOpacity, Alert, Image, FlatList } from 'react-native' +import { MaterialCommunityIcons } from '@expo/vector-icons' +import { MyText, tw, MyTouchableOpacity, useManualRefresh, useMarkDataFetchers } from 'common-ui' +import { trpc } from '@/src/trpc-client' +import { useRouter } from 'expo-router' +import dayjs from 'dayjs' +import { LinearGradient } from 'expo-linear-gradient' + +export default function OffersList() { + const router = useRouter() + const { data, isLoading, refetch } = trpc.admin.offers.getAll.useQuery() + const toggleSuspend = trpc.admin.offers.toggleSuspend.useMutation({ + onSuccess: () => refetch(), + }) + const deleteOffer = trpc.admin.offers.delete.useMutation({ + onSuccess: () => refetch(), + }) + + const offers = data?.offers || [] + + useManualRefresh(() => refetch()) + useMarkDataFetchers(() => refetch()) + + if (isLoading) { + return ( + + Loading offers... + + ) + } + + const handleToggleSuspend = (id: number) => { + toggleSuspend.mutate({ id }) + } + + const handleDelete = (id: number) => { + Alert.alert('Delete Offer', 'Are you sure you want to delete this offer?', [ + { text: 'Cancel', style: 'cancel' }, + { text: 'Delete', style: 'destructive', onPress: () => deleteOffer.mutate({ id }) }, + ]) + } + + return ( + + item.id.toString()} + contentContainerStyle={tw`p-4`} + ListEmptyComponent={ + + + No offers yet + Create your first offer + + } + renderItem={({ item: offer }: { item: any }) => ( + router.push(`/offers/edit/${offer.id}` as any)} + style={tw`bg-white border border-gray-200 rounded-2xl mb-4 overflow-hidden shadow-sm`} + > + + {offer.images?.[0] ? ( + + ) : ( + + + + )} + {offer.isSuspended && ( + + Suspended + + )} + + + + + {offer.name} + + + {offer.shortDescription && ( + + {offer.shortDescription} + + )} + + + ₹{offer.price} + {offer.marketPrice && Number(offer.marketPrice) > Number(offer.price) && ( + + ₹{offer.marketPrice} + + )} + + + + + + {offer.items?.length || 0} product{(offer.items?.length || 0) !== 1 ? 's' : ''} + + + + + Added {dayjs(offer.addedOn).format('DD MMM, YYYY')} + + + + + handleToggleSuspend(offer.id)} + style={tw`flex-1 py-3 items-center ${offer.isSuspended ? 'bg-green-50' : 'bg-red-50'}`} + > + + {offer.isSuspended ? 'Activate' : 'Suspend'} + + + handleDelete(offer.id)} + style={tw`flex-1 py-3 items-center bg-red-50 border-l border-gray-100`} + > + Delete + + + + )} + /> + + router.push('/offers/add' as any)} + style={{ position: 'absolute', bottom: 32, right: 24, zIndex: 100 }} + > + + + + + + ) +} diff --git a/apps/admin-ui/components/OfferForm.tsx b/apps/admin-ui/components/OfferForm.tsx new file mode 100644 index 0000000..5cb9793 --- /dev/null +++ b/apps/admin-ui/components/OfferForm.tsx @@ -0,0 +1,312 @@ +import React, { useState } from 'react' +import { View, Text, TouchableOpacity, Alert, ScrollView } from 'react-native' +import { Formik, FieldArray } from 'formik' +import { MyTextInput, tw, ImageUploaderNeo, Checkbox, type ImageUploaderNeoItem, type ImageUploaderNeoPayload } from 'common-ui' +import { trpc } from '../src/trpc-client' +import ProductsSelector from '../components/ProductsSelector' +import { useUploadToObjectStorage } from '../hooks/useUploadToObjectStore' +import MaterialIcons from '@expo/vector-icons/MaterialIcons' + +interface OfferItem { + productId: number + quantity: string + product?: { id: number; name: string; images: string[]; unitShortNotation: string } +} + +interface OfferFormProps { + onOfferAdded?: () => void + offerId?: number + initialName?: string + initialShortDescription?: string + initialLongDescription?: string + initialPrice?: string + initialMarketPrice?: string + initialImages?: string[] + initialImageKeys?: string[] + initialIsSuspended?: boolean + initialIsFlashEnabled?: boolean + initialItems?: OfferItem[] +} + +export default function OfferForm({ + onOfferAdded, + offerId, + initialName = '', + initialShortDescription = '', + initialLongDescription = '', + initialPrice = '', + initialMarketPrice = '', + initialImages = [], + initialImageKeys = [], + initialIsSuspended = false, + initialIsFlashEnabled = false, + initialItems = [], +}: OfferFormProps) { + const isEditMode = !!offerId + + const { mutate: createOffer, isPending: isCreating } = trpc.admin.offers.create.useMutation() + const { mutate: updateOffer, isPending: isUpdating } = trpc.admin.offers.update.useMutation() + const { upload, isUploading } = useUploadToObjectStorage() + const isPending = isCreating || isUpdating || isUploading + + const [images, setImages] = useState( + initialImages.map((url) => ({ imgUrl: url, mimeType: 'image/jpeg' })) + ) + + const initialValues = { + name: initialName, + shortDescription: initialShortDescription, + longDescription: initialLongDescription, + price: initialPrice, + marketPrice: initialMarketPrice, + isFlashEnabled: initialIsFlashEnabled, + items: initialItems.length > 0 + ? initialItems.map(item => ({ productId: item.productId, quantity: item.quantity })) + : [{ productId: 0, quantity: '1' }], + } + + const handleSubmit = async (values: typeof initialValues) => { + if (!values.name.trim()) { + Alert.alert('Error', 'Offer name is required') + return + } + + if (!values.price || Number(values.price) <= 0) { + Alert.alert('Error', 'Valid price is required') + return + } + + const validItems = values.items.filter(item => item.productId > 0) + if (validItems.length === 0) { + Alert.alert('Error', 'At least one product is required') + return + } + + let imageUrls: string[] = [] + + // Build map of signed URL → raw S3 key for existing images + const signedUrlToKey: Record = {} + initialImages.forEach((url, i) => { + if (initialImageKeys[i]) { + signedUrlToKey[url] = initialImageKeys[i] + } + }) + + // Existing images that are still present → use their raw S3 keys + const keptImageKeys = images + .filter(img => img.imgUrl.startsWith('http')) + .map(img => signedUrlToKey[img.imgUrl]) + .filter(Boolean) + + // New images (local file:// URIs) → upload to S3 + const newImages = images.filter(img => !img.imgUrl.startsWith('http')) + + if (newImages.length > 0) { + try { + const blobs = await Promise.all( + newImages.map(async (img) => { + const response = await fetch(img.imgUrl) + const blob = await response.blob() + return { blob, mimeType: img.mimeType || 'image/jpeg' } + }) + ) + const result = await upload({ images: blobs, contextString: 'product_info' as any }) + imageUrls = [...keptImageKeys, ...result.keys] + } catch (error: any) { + Alert.alert('Error', error.message || 'Failed to upload images') + return + } + } else { + imageUrls = keptImageKeys + } + + const offerData = { + name: values.name.trim(), + shortDescription: values.shortDescription.trim() || undefined, + longDescription: values.longDescription.trim() || undefined, + price: values.price, + marketPrice: values.marketPrice || undefined, + images: imageUrls, + isFlashEnabled: values.isFlashEnabled, + items: validItems.map(item => ({ + productId: item.productId, + quantity: item.quantity, + })), + } + + if (isEditMode && offerId) { + updateOffer( + { id: offerId, ...offerData } as any, + { + onSuccess: () => { + Alert.alert('Success', 'Offer updated successfully!') + onOfferAdded?.() + }, + onError: (error: any) => { + Alert.alert('Error', error.message || 'Failed to update offer') + }, + } + ) + } else { + createOffer( + offerData as any, + { + onSuccess: () => { + Alert.alert('Success', 'Offer created successfully!') + onOfferAdded?.() + }, + onError: (error: any) => { + Alert.alert('Error', error.message || 'Failed to create offer') + }, + } + ) + } + } + + return ( + + + {isEditMode ? 'Edit Offer' : 'Create New Offer'} + + + + {({ handleSubmit, values, setFieldValue }) => ( + + + setFieldValue('name', text)} + /> + + + + setFieldValue('shortDescription', text)} + multiline + /> + + + + setFieldValue('longDescription', text)} + multiline + /> + + + + Offer Images + setImages(prev => [...prev, ...newImages.map((img: ImageUploaderNeoPayload) => ({ + imgUrl: img.url, + mimeType: img.mimeType, + }))])} + onImageRemove={(img) => setImages(prev => prev.filter(i => i.imgUrl !== img.url))} + /> + + + + + setFieldValue('price', text)} + keyboardType="numeric" + /> + + + setFieldValue('marketPrice', text)} + keyboardType="numeric" + /> + + + + + setFieldValue('isFlashEnabled', !values.isFlashEnabled)} + /> + Flash Enabled + + + + Products in Offer + + {({ push, remove }) => ( + + {values.items.map((item, index) => ( + + + Product {index + 1} + {values.items.length > 1 && ( + remove(index)}> + + + )} + + + + setFieldValue(`items.${index}.productId`, Array.isArray(value) ? value[0] : value)} + multiple={false} + placeholder="Select a product" + /> + + + setFieldValue(`items.${index}.quantity`, text)} + keyboardType="numeric" + /> + + ))} + + push({ productId: 0, quantity: '1' })} + style={tw`bg-blue-500 px-4 py-3 rounded-xl items-center mb-4`} + > + Add Product + + + )} + + + + handleSubmit()} + disabled={isPending} + style={tw`${isPending ? 'bg-pink-300' : 'bg-pink-500'} p-4 rounded-xl items-center mt-4`} + > + + {isPending ? (isEditMode ? 'Updating...' : 'Creating...') : (isEditMode ? 'Update Offer' : 'Create Offer')} + + + + )} + + + ) +} diff --git a/apps/admin-ui/components/SlotForm.tsx b/apps/admin-ui/components/SlotForm.tsx index d99a20d..78dfc86 100644 --- a/apps/admin-ui/components/SlotForm.tsx +++ b/apps/admin-ui/components/SlotForm.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { View, Text, TouchableOpacity, Alert } from 'react-native'; import { Formik, FieldArray } from 'formik'; import DateTimePickerMod from 'common-ui/src/components/date-time-picker'; -import { tw, MyTextInput } from 'common-ui'; +import { tw, MyTextInput, BottomDropdown } from 'common-ui'; import { trpc } from '../src/trpc-client'; import ProductsSelector from '../components/ProductsSelector'; @@ -49,6 +49,7 @@ export default function SlotForm({ freezeTime: initialFreezeTime || (slotData?.slot?.freezeTime ? new Date(slotData.slot.freezeTime) : null), selectedGroupIds: initialGroupIds.length > 0 ? initialGroupIds : (slotData?.slot?.groupIds || []), selectedProductIds: initialProductIds.length > 0 ? initialProductIds : (slotData?.slot?.products?.map((p: any) => p.id) || []), + selectedOfferIds: slotData?.slot?.offerIds || [], vendorSnippetList: vendorSnippetsFromSlot, }; @@ -61,6 +62,9 @@ export default function SlotForm({ // Fetch groups const { data: groupsData } = trpc.admin.product.getGroups.useQuery(); + // Fetch offers + const { data: offersData } = trpc.admin.offers.getAll.useQuery(); + @@ -84,6 +88,7 @@ export default function SlotForm({ isActive: initialIsActive, groupIds: values.selectedGroupIds, productIds: values.selectedProductIds, + offerIds: values.selectedOfferIds, vendorSnippets: values.vendorSnippetList.map((snippet: VendorSnippet) => ({ name: snippet.name, productIds: snippet.productIds, @@ -178,6 +183,24 @@ export default function SlotForm({ /> + + ({ + label: `${offer.name} (₹${offer.price})`, + value: String(offer.id), + }))} + value={values.selectedOfferIds.map((id: number) => String(id))} + onValueChange={(value) => { + const selectedValues = Array.isArray(value) ? value : typeof value === 'string' ? [value] : [] + setFieldValue('selectedOfferIds', selectedValues.map((v: string) => Number(v))) + }} + placeholder="Select offers for this slot" + multiple={true} + /> + + {/* Vendor Snippets */} {({ push, remove }) => ( diff --git a/apps/backend/src/lib/cloud_cache.ts b/apps/backend/src/lib/cloud_cache.ts index c748771..3f034d9 100644 --- a/apps/backend/src/lib/cloud_cache.ts +++ b/apps/backend/src/lib/cloud_cache.ts @@ -1,5 +1,6 @@ import { Buffer } from 'buffer' import { scaffoldProducts } from '@/src/trpc/apis/common-apis/common' +import { scaffoldOffers } from '@/src/trpc/apis/common-apis/common' import { scaffoldEssentialConsts } from '@/src/trpc/apis/common-apis/common-trpc-index' import { scaffoldStores } from '@/src/trpc/apis/user-apis/apis/stores' import { scaffoldSlotsWithProducts } from '@/src/trpc/apis/user-apis/apis/slots' @@ -24,6 +25,7 @@ export interface CreateAllCacheFilesResult { stores: string slots: string banners: string + offers: string individualStores: string[] } @@ -39,6 +41,7 @@ export async function createAllCacheFiles(): Promise storesKey, slotsKey, bannersKey, + offersKey, individualStoreKeys, ] = await Promise.all([ createProductsFileInternal(cacheVersion), @@ -46,6 +49,7 @@ export async function createAllCacheFiles(): Promise createStoresFileInternal(cacheVersion), createSlotsFileInternal(cacheVersion), createBannersFileInternal(cacheVersion), + createOffersFileInternal(cacheVersion), createAllStoresFilesInternal(cacheVersion), ]) @@ -58,6 +62,7 @@ export async function createAllCacheFiles(): Promise constructCacheUrl(CACHE_FILENAMES.stores, cacheVersion), constructCacheUrl(CACHE_FILENAMES.slots, cacheVersion), constructCacheUrl(CACHE_FILENAMES.banners, cacheVersion), + constructCacheUrl(CACHE_FILENAMES.offers, cacheVersion), ...stores.map((store) => constructCacheUrl(`stores/${store.id}.json`, cacheVersion)), ] @@ -78,6 +83,7 @@ export async function createAllCacheFiles(): Promise stores: storesKey, slots: slotsKey, banners: bannersKey, + offers: offersKey, individualStores: individualStoreKeys, } } @@ -98,6 +104,17 @@ async function createProductsFileInternal(version: number): Promise { } +async function createOffersFileInternal(version: number): Promise { + const offersData = await scaffoldOffers() + const jsonContent = JSON.stringify(offersData, null, 2) + const buffer = Buffer.from(jsonContent, 'utf-8') + return await imageUploadS3( + buffer, + 'application/json', + `${getApiCacheKey()}/${buildCachePath(CACHE_FILENAMES.offers, version)}` + ) +} + async function createEssentialConstsFileInternal(version: number): Promise { const essentialConstsData = await scaffoldEssentialConsts() const jsonContent = JSON.stringify(essentialConstsData, null, 2) diff --git a/apps/backend/src/stores/slot-store.ts b/apps/backend/src/stores/slot-store.ts index 02c6bb5..0892439 100644 --- a/apps/backend/src/stores/slot-store.ts +++ b/apps/backend/src/stores/slot-store.ts @@ -13,6 +13,7 @@ interface SlotWithProducts { freezeTime: Date isActive: boolean isCapacityFull: boolean + offerIds?: number[] products: Array<{ id: number name: string @@ -42,6 +43,7 @@ async function transformSlotToStoreSlot(slot: SlotWithProductsData): Promise ({ id: product.id, name: product.name, diff --git a/apps/backend/src/trpc/apis/admin-apis/apis/admin-trpc-index.ts b/apps/backend/src/trpc/apis/admin-apis/apis/admin-trpc-index.ts index ede995e..bf256f1 100644 --- a/apps/backend/src/trpc/apis/admin-apis/apis/admin-trpc-index.ts +++ b/apps/backend/src/trpc/apis/admin-apis/apis/admin-trpc-index.ts @@ -12,6 +12,7 @@ import { adminPaymentsRouter } from '@/src/trpc/apis/admin-apis/apis/payments' import { bannerRouter } from '@/src/trpc/apis/admin-apis/apis/banner' import { userRouter } from '@/src/trpc/apis/admin-apis/apis/user' import { constRouter } from '@/src/trpc/apis/admin-apis/apis/const' +import { offersRouter } from '@/src/trpc/apis/admin-apis/apis/offers' export const adminRouter = router({ complaint: complaintRouter, @@ -26,6 +27,7 @@ export const adminRouter = router({ banner: bannerRouter, user: userRouter, const: constRouter, + offers: offersRouter, }); export type AdminRouter = typeof adminRouter; diff --git a/apps/backend/src/trpc/apis/admin-apis/apis/offers.ts b/apps/backend/src/trpc/apis/admin-apis/apis/offers.ts new file mode 100644 index 0000000..66629a1 --- /dev/null +++ b/apps/backend/src/trpc/apis/admin-apis/apis/offers.ts @@ -0,0 +1,138 @@ +import { router, protectedProcedure } from '@/src/trpc/trpc-index' +import { z } from 'zod' +import { ApiError } from '@/src/lib/api-error' +import { scaffoldAssetUrl, deleteImageUtil } from '@/src/lib/s3-client' +import { + getAllOffers as getAllOffersFromDb, + getOfferById as getOfferByIdFromDb, + createOffer as createOfferInDb, + updateOffer as updateOfferInDb, + deleteOffer as deleteOfferFromDb, + toggleSuspendOffer as toggleSuspendOfferInDb, +} from '@/src/dbService' + +const offerItemInput = z.object({ + productId: z.number(), + quantity: z.string(), +}) + +export const offersRouter = router({ + getAll: protectedProcedure + .query(async (): Promise<{ offers: any[]; count: number }> => { + const offers = await getAllOffersFromDb() + + const offersWithSignedImages = offers.map(offer => ({ + ...offer, + imageKeys: offer.images || [], + images: (offer.images || []).map((img: string) => scaffoldAssetUrl(img)), + items: offer.items.map((item: any) => ({ + ...item, + product: item.product ? { + ...item.product, + images: (item.product.images || []).map((img: string) => scaffoldAssetUrl(img)), + } : null, + })), + })) + + return { offers: offersWithSignedImages, count: offers.length } + }), + + getById: protectedProcedure + .input(z.object({ id: z.number() })) + .query(async ({ input }): Promise<{ offer: any }> => { + const offer = await getOfferByIdFromDb(input.id) + if (!offer) throw new ApiError('Offer not found', 404) + + const signedOffer = { + ...offer, + imageKeys: offer.images || [], + images: (offer.images || []).map((img: string) => scaffoldAssetUrl(img)), + items: offer.items.map((item: any) => ({ + ...item, + product: item.product ? { + ...item.product, + images: (item.product.images || []).map((img: string) => scaffoldAssetUrl(img)), + } : null, + })), + } + + return { offer: signedOffer } + }), + + create: protectedProcedure + .input(z.object({ + name: z.string().min(1, 'Name is required'), + shortDescription: z.string().optional(), + longDescription: z.string().optional(), + price: z.string(), + marketPrice: z.string().optional(), + images: z.array(z.string()).optional().default([]), + isSuspended: z.boolean().optional().default(false), + isFlashEnabled: z.boolean().optional().default(false), + items: z.array(offerItemInput).min(1, 'At least one product is required'), + })) + .mutation(async ({ input }) => { + const offer = await createOfferInDb(input) + return { + offer: { + ...offer, + images: (offer.images || []).map((img: string) => scaffoldAssetUrl(img)), + }, + message: 'Offer created successfully', + } + }), + + update: protectedProcedure + .input(z.object({ + id: z.number(), + name: z.string().min(1).optional(), + shortDescription: z.string().optional(), + longDescription: z.string().optional(), + price: z.string().optional(), + marketPrice: z.string().optional(), + images: z.array(z.string()).optional(), + isSuspended: z.boolean().optional(), + isFlashEnabled: z.boolean().optional(), + items: z.array(offerItemInput).optional(), + })) + .mutation(async ({ input }) => { + const { id, ...updateData } = input + const offer = await updateOfferInDb(id, updateData as any) + return { + offer: { + ...offer, + images: (offer.images || []).map((img: string) => scaffoldAssetUrl(img)), + }, + message: 'Offer updated successfully', + } + }), + + delete: protectedProcedure + .input(z.object({ id: z.number() })) + .mutation(async ({ input }) => { + const offer = await getOfferByIdFromDb(input.id) + if (!offer) throw new ApiError('Offer not found', 404) + + if (offer.images?.length) { + await deleteImageUtil({ keys: offer.images }) + } + + await deleteOfferFromDb(input.id) + return { message: 'Offer deleted successfully' } + }), + + toggleSuspend: protectedProcedure + .input(z.object({ id: z.number() })) + .mutation(async ({ input }) => { + const offer = await toggleSuspendOfferInDb(input.id) + return { + offer: { + ...offer, + images: (offer.images || []).map((img: string) => scaffoldAssetUrl(img)), + }, + message: `Offer ${offer.isSuspended ? 'suspended' : 'activated'} successfully`, + } + }), +}) + +export type OffersRouter = typeof offersRouter diff --git a/apps/backend/src/trpc/apis/admin-apis/apis/slots.ts b/apps/backend/src/trpc/apis/admin-apis/apis/slots.ts index dcfbfdf..3a41ef7 100644 --- a/apps/backend/src/trpc/apis/admin-apis/apis/slots.ts +++ b/apps/backend/src/trpc/apis/admin-apis/apis/slots.ts @@ -53,6 +53,7 @@ const createSlotSchema = z.object({ validTill: z.string().optional(), })).optional(), groupIds: z.array(z.number()).optional(), + offerIds: z.array(z.number()).optional(), }); const getSlotByIdSchema = z.object({ @@ -71,6 +72,7 @@ const updateSlotSchema = z.object({ validTill: z.string().optional(), })).optional(), groupIds: z.array(z.number()).optional(), + offerIds: z.array(z.number()).optional(), }); const deleteSlotSchema = z.object({ @@ -282,7 +284,7 @@ export const slotsRouter = router({ throw new TRPCError({ code: "UNAUTHORIZED", message: "Access denied" }); } - const { deliveryTime, freezeTime, isActive, productIds, vendorSnippets: snippets, groupIds } = input; + const { deliveryTime, freezeTime, isActive, productIds, vendorSnippets: snippets, groupIds, offerIds } = input; // Validate required fields if (!deliveryTime || !freezeTime) { @@ -296,6 +298,7 @@ export const slotsRouter = router({ productIds, vendorSnippets: snippets, groupIds, + offerIds, }) /* @@ -445,7 +448,7 @@ export const slotsRouter = router({ throw new TRPCError({ code: "UNAUTHORIZED", message: "Access denied" }); } try{ - const { id, deliveryTime, freezeTime, isActive, productIds, vendorSnippets: snippets, groupIds } = input; + const { id, deliveryTime, freezeTime, isActive, productIds, vendorSnippets: snippets, groupIds, offerIds } = input; if (!deliveryTime || !freezeTime) { throw new ApiError("Delivery time and orders close time are required", 400); @@ -459,6 +462,7 @@ export const slotsRouter = router({ productIds, vendorSnippets: snippets, groupIds, + offerIds, }) /* diff --git a/apps/backend/src/trpc/apis/common-apis/common.ts b/apps/backend/src/trpc/apis/common-apis/common.ts index 64cac9b..341e5de 100644 --- a/apps/backend/src/trpc/apis/common-apis/common.ts +++ b/apps/backend/src/trpc/apis/common-apis/common.ts @@ -3,6 +3,7 @@ import { getSuspendedProductIds, getNextDeliveryDateWithCapacity, getStoresSummary, + getAllOffers as getAllOffersFromDb, } from '@/src/dbService' import { generateSignedUrlsFromS3Urls, generateSignedUrlFromS3Url } from '@/src/lib/s3-client' import { getAllProducts as getAllProductsFromCache } from '@/src/stores/product-store' @@ -64,6 +65,37 @@ export async function scaffoldProducts() { }; } +export async function scaffoldOffers() { + const allOffers = await getAllOffersFromDb() + + const activeOffers = allOffers.filter((offer: any) => !offer.isSuspended) + + const formattedOffers = activeOffers.map((offer: any) => ({ + id: offer.id, + name: offer.name, + shortDescription: offer.shortDescription, + longDescription: offer.longDescription, + price: offer.price, + marketPrice: offer.marketPrice || null, + images: offer.images || [], + isFlashEnabled: offer.isFlashEnabled, + items: (offer.items || []).map((item: any) => ({ + productId: item.productId, + quantity: item.quantity, + product: item.product ? { + id: item.product.id, + name: item.product.name, + images: item.product.images || [], + } : null, + })), + })) + + return { + offers: formattedOffers, + count: formattedOffers.length, + } +} + export const commonRouter = router({ getDashboardTags: publicProcedure .query(async () => { diff --git a/apps/backend/src/trpc/apis/user-apis/apis/slots.ts b/apps/backend/src/trpc/apis/user-apis/apis/slots.ts index 265f42e..fe6c457 100644 --- a/apps/backend/src/trpc/apis/user-apis/apis/slots.ts +++ b/apps/backend/src/trpc/apis/user-apis/apis/slots.ts @@ -2,7 +2,8 @@ import { router, publicProcedure } from "@/src/trpc/trpc-index" import { z } from "zod" import { getAllSlots as getAllSlotsFromCache, getSlotById as getSlotByIdFromCache } from "@/src/stores/slot-store" import dayjs from 'dayjs' -import { getUserActiveSlotsList as getUserActiveSlotsListInDb, getUserProductAvailability as getUserProductAvailabilityInDb } from '@/src/dbService' +import { getUserActiveSlotsList as getUserActiveSlotsListInDb, getUserProductAvailability as getUserProductAvailabilityInDb, getAllOffers as getAllOffersFromDb } from '@/src/dbService' +import { scaffoldAssetUrl } from '@/src/lib/s3-client' import type { UserSlotData, UserSlotsListResponse, UserSlotsWithProductsResponse } from '@packages/shared' // Helper method to get formatted slot data by ID @@ -28,6 +29,7 @@ async function getSlotData(slotId: number) { export async function scaffoldSlotsWithProducts(): Promise { const allSlots = await getAllSlotsFromCache(); + const allOffers = await getAllOffersFromDb() const currentTime = new Date(); const validSlots = allSlots .filter((slot) => { @@ -39,28 +41,38 @@ export async function scaffoldSlotsWithProducts(): Promise = {} + allOffers.forEach((offer: any) => { + if (!offer.isSuspended) { + offersMap[offer.id] = { + id: offer.id, + name: offer.name, + shortDescription: offer.shortDescription, + longDescription: offer.longDescription, + price: offer.price, + marketPrice: offer.marketPrice || null, + images: (offer.images || []).map((img: string) => scaffoldAssetUrl(img)), + isFlashEnabled: offer.isFlashEnabled, + items: (offer.items || []).map((item: any) => ({ + productId: item.productId, + quantity: item.quantity, + product: item.product ? { + id: item.product.id, + name: item.product.name, + images: item.product.images, + } : null, + })), + } + } + }) - const productAvailability = allProducts.map(product => ({ - id: product.id, - name: product.name, - isOutOfStock: product.isOutOfStock, - isFlashAvailable: product.isFlashAvailable, - })); - */ + const slotsWithOffers = validSlots.map(slot => ({ + ...slot, + offers: (slot.offerIds || []).map((id: number) => offersMap[id]).filter(Boolean), + })) return { - slots: validSlots, + slots: slotsWithOffers, productAvailability, count: validSlots.length, }; diff --git a/apps/backend/worker.ts b/apps/backend/worker.ts index d8ea2ec..934e6c1 100644 --- a/apps/backend/worker.ts +++ b/apps/backend/worker.ts @@ -12,11 +12,13 @@ import { handleOrderPlacedQueue, handleOrderCancelledQueue, } from './src/lib/queue-consumer' +import { scaffoldSlotsWithProducts } from './src/trpc/apis/user-apis/apis/slots' export { CacheCreator } let app: ReturnType | null = null + export default { async fetch( request: Request, diff --git a/apps/backend/wrangler-commands.md b/apps/backend/wrangler-commands.md index 25e25a9..6208704 100644 --- a/apps/backend/wrangler-commands.md +++ b/apps/backend/wrangler-commands.md @@ -5,3 +5,9 @@ --file dumps/latest.sql --remote # run a single file + + + + wrangler d1 execute freshyo-backend-dev \ + --config wrangler.dev.toml \ + --file ../../packages/db_helper_sqlite/drizzle/0002_offers.sql \ No newline at end of file diff --git a/apps/backend/wrangler.dev.toml b/apps/backend/wrangler.dev.toml index b05b479..99b12c3 100644 --- a/apps/backend/wrangler.dev.toml +++ b/apps/backend/wrangler.dev.toml @@ -8,8 +8,10 @@ routes = [ [[d1_databases]] binding = "DB" -database_name = "freshyo-backend-dev" -database_id = "6b93ddc9-9b24-4cfc-9320-e81aec38887a" +#database_name = "freshyo-backend-dev" +#database_id = "6b93ddc9-9b24-4cfc-9320-e81aec38887a" +database_name = "freshyo-dev" +database_id = "3cad440f-dc14-4baa-ab05-04eb08e206de" migrations_dir="../../packages/db_helper_sqlite/drizzle" migrations_pattern="migration.sql" [durable_objects] diff --git a/packages/db_helper_postgres/index.ts b/packages/db_helper_postgres/index.ts index f51bba1..7c5983d 100644 --- a/packages/db_helper_postgres/index.ts +++ b/packages/db_helper_postgres/index.ts @@ -186,6 +186,16 @@ export { getVendorOrders, } from './src/admin-apis/vendor-snippets'; +export { + // Offers + getAllOffers, + getOfferById, + createOffer, + updateOffer, + deleteOffer, + toggleSuspendOffer, +} from './src/admin-apis/offers' + export { // User Address getDefaultAddress as getUserDefaultAddress, diff --git a/packages/db_helper_postgres/src/admin-apis/offers.ts b/packages/db_helper_postgres/src/admin-apis/offers.ts new file mode 100644 index 0000000..772ebfa --- /dev/null +++ b/packages/db_helper_postgres/src/admin-apis/offers.ts @@ -0,0 +1,162 @@ +import { db } from '../db/db_index' +import { offers, offerItems, productInfo, units } from '../db/schema' +import { eq, desc } from 'drizzle-orm' + +export interface OfferItemInput { + productId: number + quantity: string +} + +export interface CreateOfferInput { + name: string + shortDescription?: string | null + longDescription?: string | null + price: string + marketPrice?: string | null + images?: string[] | null + isSuspended?: boolean + isFlashEnabled?: boolean + items: OfferItemInput[] +} + +export interface UpdateOfferInput extends Partial {} + +export async function getAllOffers(): Promise { + const results = await db.query.offers.findMany({ + with: { + items: { + with: { + product: { + columns: { id: true, name: true, images: true }, + }, + }, + }, + }, + orderBy: desc(offers.addedOn), + }) + + return results.map((offer: any) => ({ + ...offer, + images: offer.images || [], + items: (offer.items || []).map((item: any) => ({ + id: item.id, + offerId: item.offerId, + productId: item.productId, + quantity: item.quantity, + product: item.product ? { + id: item.product.id, + name: item.product.name, + images: item.product.images || [], + } : null, + })), + })) +} + +export async function getOfferById(id: number): Promise { + const result = await db.query.offers.findFirst({ + where: eq(offers.id, id), + with: { + items: { + with: { + product: { + columns: { id: true, name: true, images: true }, + }, + }, + }, + }, + }) + + if (!result) return null + + return { + ...result, + images: result.images || [], + items: (result.items || []).map((item: any) => ({ + id: item.id, + offerId: item.offerId, + productId: item.productId, + quantity: item.quantity, + product: item.product ? { + id: item.product.id, + name: item.product.name, + images: item.product.images || [], + } : null, + })), + } +} + +export async function createOffer(input: CreateOfferInput): Promise { + const [offer] = await db.insert(offers).values({ + name: input.name, + shortDescription: input.shortDescription || null, + longDescription: input.longDescription || null, + price: numericStr(input.price), + marketPrice: input.marketPrice ? numericStr(input.marketPrice) : null, + images: input.images || [], + isSuspended: input.isSuspended || false, + isFlashEnabled: input.isFlashEnabled || false, + }).returning() + + if (input.items.length > 0) { + await db.insert(offerItems).values( + input.items.map(item => ({ + offerId: offer.id, + productId: item.productId, + quantity: item.quantity, + })) + ) + } + + return getOfferById(offer.id) +} + +export async function updateOffer(id: number, input: UpdateOfferInput): Promise { + const updateData: Record = {} + if (input.name !== undefined) updateData.name = input.name + if (input.shortDescription !== undefined) updateData.shortDescription = input.shortDescription + if (input.longDescription !== undefined) updateData.longDescription = input.longDescription + if (input.price !== undefined) updateData.price = numericStr(input.price) + if (input.marketPrice !== undefined) updateData.marketPrice = input.marketPrice ? numericStr(input.marketPrice) : null + if (input.images !== undefined) updateData.images = input.images + if (input.isSuspended !== undefined) updateData.isSuspended = input.isSuspended + if (input.isFlashEnabled !== undefined) updateData.isFlashEnabled = input.isFlashEnabled + + if (Object.keys(updateData).length > 0) { + await db.update(offers).set(updateData).where(eq(offers.id, id)) + } + + if (input.items !== undefined) { + await db.delete(offerItems).where(eq(offerItems.offerId, id)) + if (input.items.length > 0) { + await db.insert(offerItems).values( + input.items.map(item => ({ + offerId: id, + productId: item.productId, + quantity: item.quantity, + })) + ) + } + } + + return getOfferById(id) +} + +export async function deleteOffer(id: number): Promise { + await db.delete(offerItems).where(eq(offerItems.offerId, id)) + await db.delete(offers).where(eq(offers.id, id)) +} + +export async function toggleSuspendOffer(id: number): Promise { + const existing = await getOfferById(id) + if (!existing) throw new Error('Offer not found') + + await db.update(offers) + .set({ isSuspended: !existing.isSuspended }) + .where(eq(offers.id, id)) + + return getOfferById(id) +} + +function numericStr(value: string): string { + return value +} diff --git a/packages/db_helper_postgres/src/admin-apis/slots.ts b/packages/db_helper_postgres/src/admin-apis/slots.ts index 96ef6af..c1ffdbb 100644 --- a/packages/db_helper_postgres/src/admin-apis/slots.ts +++ b/packages/db_helper_postgres/src/admin-apis/slots.ts @@ -179,8 +179,9 @@ export async function createSlotWithRelations(input: { productIds?: number[] vendorSnippets?: SlotSnippetInput[] groupIds?: number[] + offerIds?: number[] }): Promise { - const { deliveryTime, freezeTime, isActive, productIds, vendorSnippets: snippets, groupIds } = input + const { deliveryTime, freezeTime, isActive, productIds, vendorSnippets: snippets, groupIds, offerIds } = input const result = await db.transaction(async (tx) => { const [newSlot] = await tx @@ -191,6 +192,7 @@ export async function createSlotWithRelations(input: { isActive: isActive !== undefined ? isActive : true, groupIds: groupIds !== undefined ? groupIds : [], productIds: productIds !== undefined ? productIds : [], + offerIds: offerIds || [], }) .returning() @@ -240,8 +242,9 @@ export async function updateSlotWithRelations(input: { productIds?: number[] vendorSnippets?: SlotSnippetInput[] groupIds?: number[] + offerIds?: number[] }): Promise { - const { id, deliveryTime, freezeTime, isActive, productIds, vendorSnippets: snippets, groupIds } = input + const { id, deliveryTime, freezeTime, isActive, productIds, vendorSnippets: snippets, groupIds, offerIds } = input let validGroupIds = groupIds if (groupIds && groupIds.length > 0) { @@ -261,6 +264,7 @@ export async function updateSlotWithRelations(input: { isActive: isActive !== undefined ? isActive : true, groupIds: validGroupIds !== undefined ? validGroupIds : [], ...(productIds !== undefined && { productIds }), + ...(offerIds !== undefined && { offerIds }), }) .where(eq(deliverySlotInfo.id, id)) .returning() diff --git a/packages/db_helper_postgres/src/db/schema.ts b/packages/db_helper_postgres/src/db/schema.ts index 97687af..6c5efdd 100755 --- a/packages/db_helper_postgres/src/db/schema.ts +++ b/packages/db_helper_postgres/src/db/schema.ts @@ -196,6 +196,7 @@ export const deliverySlotInfo = mf.table('delivery_slot_info', { deliverySequence: jsonb('delivery_sequence').$defaultFn(() => {}), groupIds: jsonb('group_ids').$defaultFn(() => []), productIds: jsonb('product_ids').$defaultFn(() => []), + offerIds: jsonb('offer_ids').$defaultFn(() => []), }); export const vendorSnippets = mf.table('vendor_snippets', { @@ -212,6 +213,28 @@ export const vendorSnippetsRelations = relations(vendorSnippets, ({ one }) => ({ slot: one(deliverySlotInfo, { fields: [vendorSnippets.slotId], references: [deliverySlotInfo.id] }), })); +export const offers = mf.table('offers', { + id: integer().primaryKey().generatedAlwaysAsIdentity(), + name: varchar({ length: 255 }).notNull(), + shortDescription: varchar('short_description', { length: 500 }), + longDescription: varchar('long_description', { length: 1000 }), + price: numeric({ precision: 10, scale: 2 }).notNull(), + marketPrice: numeric('market_price', { precision: 10, scale: 2 }), + images: jsonb('images').$defaultFn(() => []), + isSuspended: boolean('is_suspended').notNull().default(false), + isFlashEnabled: boolean('is_flash_enabled').notNull().default(false), + addedOn: timestamp('added_on').notNull().defaultNow(), +}) + +export const offerItems = mf.table('offer_items', { + id: integer().primaryKey().generatedAlwaysAsIdentity(), + offerId: integer('offer_id').notNull().references(() => offers.id), + productId: integer('product_id').notNull().references(() => productInfo.id), + quantity: varchar('quantity', { length: 20 }).notNull(), +}, (t) => ({ + unq_offer_product: unique('unique_offer_product').on(t.offerId, t.productId), +})) + export const specialDeals = mf.table('special_deals', { id: integer().primaryKey().generatedAlwaysAsIdentity(), productId: integer('product_id').notNull().references(() => productInfo.id), @@ -673,4 +696,13 @@ export const userIncidentsRelations = relations(userIncidents, ({ one }) => ({ user: one(users, { fields: [userIncidents.userId], references: [users.id] }), order: one(orders, { fields: [userIncidents.orderId], references: [orders.id] }), addedBy: one(staffUsers, { fields: [userIncidents.addedBy], references: [staffUsers.id] }), +})) + +export const offersRelations = relations(offers, ({ many }) => ({ + items: many(offerItems), +})) + +export const offerItemsRelations = relations(offerItems, ({ one }) => ({ + offer: one(offers, { fields: [offerItems.offerId], references: [offers.id] }), + product: one(productInfo, { fields: [offerItems.productId], references: [productInfo.id] }), })); diff --git a/packages/db_helper_sqlite/drizzle/0002_offers.sql b/packages/db_helper_sqlite/drizzle/0002_offers.sql new file mode 100644 index 0000000..a0bdd83 --- /dev/null +++ b/packages/db_helper_sqlite/drizzle/0002_offers.sql @@ -0,0 +1,30 @@ +-- Migration: Add offers tables and delivery slot offer_ids +-- Creates the offers feature: bundles of products at a fixed price + +-- Step 1: Create offers table +CREATE TABLE `offers` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `name` text NOT NULL, + `short_description` text, + `long_description` text, + `price` text NOT NULL, + `market_price` text, + `images` text DEFAULT '[]', + `is_suspended` integer DEFAULT 0 NOT NULL, + `is_flash_enabled` integer DEFAULT 0 NOT NULL, + `added_on` text NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- Step 2: Create offer_items junction table +CREATE TABLE `offer_items` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `offer_id` integer NOT NULL REFERENCES `offers`(`id`), + `product_id` integer NOT NULL REFERENCES `product_info`(`id`), + `quantity` text NOT NULL +); + +-- Step 3: Unique index on offer + product +CREATE UNIQUE INDEX `unique_offer_product` ON `offer_items` (`offer_id`, `product_id`); + +-- Step 4: Add offer_ids array to delivery slots +ALTER TABLE `delivery_slot_info` ADD COLUMN `offer_ids` text DEFAULT '[]'; diff --git a/packages/db_helper_sqlite/drizzle/meta/_journal.json b/packages/db_helper_sqlite/drizzle/meta/_journal.json index b4636c1..dae4ee2 100644 --- a/packages/db_helper_sqlite/drizzle/meta/_journal.json +++ b/packages/db_helper_sqlite/drizzle/meta/_journal.json @@ -8,6 +8,20 @@ "when": 1774588140474, "tag": "0000_nifty_sauron", "breakpoints": true + }, + { + "idx": 1, + "version": "6", + "when": 1747862400000, + "tag": "0001_migrate_product_slots", + "breakpoints": true + }, + { + "idx": 2, + "version": "6", + "when": 1756233600000, + "tag": "0002_offers", + "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/packages/db_helper_sqlite/index.ts b/packages/db_helper_sqlite/index.ts index 96826c2..b3a9675 100644 --- a/packages/db_helper_sqlite/index.ts +++ b/packages/db_helper_sqlite/index.ts @@ -185,6 +185,16 @@ export { getVendorOrders, } from './src/admin-apis/vendor-snippets' +export { + // Offers + getAllOffers, + getOfferById, + createOffer, + updateOffer, + deleteOffer, + toggleSuspendOffer, +} from './src/admin-apis/offers' + export { // User Address getDefaultAddress as getUserDefaultAddress, diff --git a/packages/db_helper_sqlite/src/admin-apis/offers.ts b/packages/db_helper_sqlite/src/admin-apis/offers.ts new file mode 100644 index 0000000..2ca1811 --- /dev/null +++ b/packages/db_helper_sqlite/src/admin-apis/offers.ts @@ -0,0 +1,167 @@ +import { db } from '../db/db_index' +import { offers, offerItems } from '../db/schema' +import { eq, desc } from 'drizzle-orm' + +export type OfferRow = typeof offers.$inferSelect +export type OfferItemRow = typeof offerItems.$inferSelect + +export interface OfferItemInput { + productId: number + quantity: string +} + +export interface CreateOfferInput { + name: string + shortDescription?: string | null + longDescription?: string | null + price: string + marketPrice?: string | null + images?: string[] | null + isSuspended?: boolean + isFlashEnabled?: boolean + items: OfferItemInput[] +} + +export interface UpdateOfferInput extends Partial {} + +export async function getAllOffers(): Promise { + const results = await db.query.offers.findMany({ + with: { + items: { + with: { + product: { + columns: { id: true, name: true, images: true }, + with: { + unit: { columns: { shortNotation: true } }, + }, + }, + }, + }, + }, + orderBy: desc(offers.addedOn), + }) + + return results.map((offer: any) => ({ + ...offer, + items: offer.items.map((item: any) => ({ + id: item.id, + offerId: item.offerId, + productId: item.productId, + quantity: item.quantity, + product: item.product ? { + id: item.product.id, + name: item.product.name, + images: item.product.images, + unitShortNotation: item.product.unit?.shortNotation || '', + } : null, + })), + })) +} + +export async function getOfferById(id: number): Promise { + const result = await db.query.offers.findFirst({ + where: eq(offers.id, id), + with: { + items: { + with: { + product: { + columns: { id: true, name: true, images: true }, + with: { + unit: { columns: { shortNotation: true } }, + }, + }, + }, + }, + }, + }) + + if (!result) return null + + return { + ...result, + items: result.items.map((item: any) => ({ + id: item.id, + offerId: item.offerId, + productId: item.productId, + quantity: item.quantity, + product: item.product ? { + id: item.product.id, + name: item.product.name, + images: item.product.images, + unitShortNotation: item.product.unit?.shortNotation || '', + } : null, + })), + } +} + +export async function createOffer(input: CreateOfferInput): Promise { + const [offer] = await db.insert(offers).values({ + name: input.name, + shortDescription: input.shortDescription || null, + longDescription: input.longDescription || null, + price: input.price, + marketPrice: input.marketPrice || null, + images: input.images || [], + isSuspended: input.isSuspended || false, + isFlashEnabled: input.isFlashEnabled || false, + }).returning() + + if (input.items.length > 0) { + await db.insert(offerItems).values( + input.items.map(item => ({ + offerId: offer.id, + productId: item.productId, + quantity: item.quantity, + })) + ) + } + + return getOfferById(offer.id) +} + +export async function updateOffer(id: number, input: UpdateOfferInput): Promise { + const updateData: Record = {} + if (input.name !== undefined) updateData.name = input.name + if (input.shortDescription !== undefined) updateData.shortDescription = input.shortDescription + if (input.longDescription !== undefined) updateData.longDescription = input.longDescription + if (input.price !== undefined) updateData.price = input.price + if (input.marketPrice !== undefined) updateData.marketPrice = input.marketPrice + if (input.images !== undefined) updateData.images = input.images + if (input.isSuspended !== undefined) updateData.isSuspended = input.isSuspended + if (input.isFlashEnabled !== undefined) updateData.isFlashEnabled = input.isFlashEnabled + + if (Object.keys(updateData).length > 0) { + await db.update(offers).set(updateData).where(eq(offers.id, id)) + } + + if (input.items !== undefined) { + await db.delete(offerItems).where(eq(offerItems.offerId, id)) + if (input.items.length > 0) { + await db.insert(offerItems).values( + input.items.map(item => ({ + offerId: id, + productId: item.productId, + quantity: item.quantity, + })) + ) + } + } + + return getOfferById(id) +} + +export async function deleteOffer(id: number): Promise { + await db.delete(offerItems).where(eq(offerItems.offerId, id)) + await db.delete(offers).where(eq(offers.id, id)) +} + +export async function toggleSuspendOffer(id: number): Promise { + const existing = await getOfferById(id) + if (!existing) throw new Error('Offer not found') + + await db.update(offers) + .set({ isSuspended: !existing.isSuspended }) + .where(eq(offers.id, id)) + + return getOfferById(id) +} diff --git a/packages/db_helper_sqlite/src/admin-apis/slots.ts b/packages/db_helper_sqlite/src/admin-apis/slots.ts index 68afef0..cc74e6f 100644 --- a/packages/db_helper_sqlite/src/admin-apis/slots.ts +++ b/packages/db_helper_sqlite/src/admin-apis/slots.ts @@ -224,8 +224,9 @@ export async function createSlotWithRelations(input: { productIds?: number[] vendorSnippets?: SlotSnippetInput[] groupIds?: number[] + offerIds?: number[] }): Promise { - const { deliveryTime, freezeTime, isActive, productIds, vendorSnippets: snippets, groupIds } = input + const { deliveryTime, freezeTime, isActive, productIds, vendorSnippets: snippets, groupIds, offerIds } = input const normalizedProductIds = normalizeProductIds(productIds) @@ -247,6 +248,7 @@ export async function createSlotWithRelations(input: { isActive: isActive !== undefined ? isActive : true, groupIds: groupIds !== undefined ? groupIds : [], productIds: normalizedProductIds, + offerIds: offerIds || [], }) .returning() @@ -296,8 +298,9 @@ export async function updateSlotWithRelations(input: { productIds?: number[] vendorSnippets?: SlotSnippetInput[] groupIds?: number[] + offerIds?: number[] }): Promise { - const { id, deliveryTime, freezeTime, isActive, productIds, vendorSnippets: snippets, groupIds } = input + const { id, deliveryTime, freezeTime, isActive, productIds, vendorSnippets: snippets, groupIds, offerIds } = input let validGroupIds = groupIds if (groupIds && groupIds.length > 0) { @@ -328,6 +331,7 @@ export async function updateSlotWithRelations(input: { isActive: isActive !== undefined ? isActive : true, groupIds: validGroupIds !== undefined ? validGroupIds : [], ...(normalizedProductIds !== undefined && { productIds: normalizedProductIds }), + ...(offerIds !== undefined && { offerIds }), }) .where(eq(deliverySlotInfo.id, id)) .returning() diff --git a/packages/db_helper_sqlite/src/db/schema.ts b/packages/db_helper_sqlite/src/db/schema.ts index 4985f39..74934a7 100644 --- a/packages/db_helper_sqlite/src/db/schema.ts +++ b/packages/db_helper_sqlite/src/db/schema.ts @@ -281,6 +281,7 @@ export const deliverySlotInfo = sqliteTable('delivery_slot_info', { deliverySequence: jsonText>('delivery_sequence').$defaultFn(() => ({})), groupIds: jsonText('group_ids').$defaultFn(() => []), productIds: jsonText('product_ids').$defaultFn(() => []), + offerIds: jsonText('offer_ids').$defaultFn(() => []), }) export const vendorSnippets = sqliteTable('vendor_snippets', { @@ -293,6 +294,28 @@ export const vendorSnippets = sqliteTable('vendor_snippets', { createdAt: timestampText('created_at').notNull().default(sql`CURRENT_TIMESTAMP`), }) +export const offers = sqliteTable('offers', { + id: integer().primaryKey({ autoIncrement: true }), + name: text().notNull(), + shortDescription: text('short_description'), + longDescription: text('long_description'), + price: numericText('price').notNull(), + marketPrice: numericText('market_price'), + images: jsonText('images').$defaultFn(() => []), + isSuspended: integer('is_suspended', { mode: 'boolean' }).notNull().default(false), + isFlashEnabled: integer('is_flash_enabled', { mode: 'boolean' }).notNull().default(false), + addedOn: timestampText('added_on').notNull().default(sql`CURRENT_TIMESTAMP`), +}) + +export const offerItems = sqliteTable('offer_items', { + id: integer().primaryKey({ autoIncrement: true }), + offerId: integer('offer_id').notNull().references(() => offers.id), + productId: integer('product_id').notNull().references(() => productInfo.id), + quantity: numericText('quantity').notNull(), +}, (t) => ({ + unq_offer_product: uniqueIndex('unique_offer_product').on(t.offerId, t.productId), +})) + export const specialDeals = sqliteTable('special_deals', { id: integer().primaryKey({ autoIncrement: true }), productId: integer('product_id').notNull().references(() => productInfo.id), @@ -730,3 +753,12 @@ export const userIncidentsRelations = relations(userIncidents, ({ one }) => ({ export const vendorSnippetsRelations = relations(vendorSnippets, ({ one }) => ({ slot: one(deliverySlotInfo, { fields: [vendorSnippets.slotId], references: [deliverySlotInfo.id] }), })) + +export const offersRelations = relations(offers, ({ many }) => ({ + items: many(offerItems), +})) + +export const offerItemsRelations = relations(offerItems, ({ one }) => ({ + offer: one(offers, { fields: [offerItems.offerId], references: [offers.id] }), + product: one(productInfo, { fields: [offerItems.productId], references: [productInfo.id] }), +})) diff --git a/packages/db_helper_sqlite/src/stores/store-helpers.ts b/packages/db_helper_sqlite/src/stores/store-helpers.ts index 4629d5c..88cd799 100644 --- a/packages/db_helper_sqlite/src/stores/store-helpers.ts +++ b/packages/db_helper_sqlite/src/stores/store-helpers.ts @@ -222,6 +222,7 @@ export interface SlotWithProductsData { freezeTime: Date isActive: boolean isCapacityFull: boolean + offerIds?: number[] products: Array<{ id: number name: string @@ -291,6 +292,7 @@ export async function getAllSlotsWithProductsForCache(): Promise productMap.get(productId)) .filter((p): p is NonNullable => p != null) diff --git a/packages/shared/index.ts b/packages/shared/index.ts index a502ff8..39b8bd4 100644 --- a/packages/shared/index.ts +++ b/packages/shared/index.ts @@ -4,6 +4,7 @@ export const CACHE_FILENAMES = { slots: 'slots.json', essentialConsts: 'essential-consts.json', banners: 'banners.json', + offers: 'offers.json', } as const export type CacheFilename = typeof CACHE_FILENAMES[keyof typeof CACHE_FILENAMES]