From d7a588a5290c52a599d5ab11fb3224e654871fbc Mon Sep 17 00:00:00 2001
From: shafi54 <108669266+shafi-aviz@users.noreply.github.com>
Date: Tue, 28 Jul 2026 01:47:38 +0530
Subject: [PATCH] enh
---
apps/admin-ui/app/(drawer)/_layout.tsx | 8 +
.../admin-ui/app/(drawer)/dashboard/index.tsx | 10 +
apps/admin-ui/app/(drawer)/offers/_layout.tsx | 11 +
apps/admin-ui/app/(drawer)/offers/add.tsx | 21 ++
.../app/(drawer)/offers/edit/[id].tsx | 67 ++++
apps/admin-ui/app/(drawer)/offers/index.tsx | 146 ++++++++
apps/admin-ui/components/OfferForm.tsx | 312 ++++++++++++++++++
apps/admin-ui/components/SlotForm.tsx | 25 +-
apps/backend/src/lib/cloud_cache.ts | 17 +
apps/backend/src/stores/slot-store.ts | 2 +
.../apis/admin-apis/apis/admin-trpc-index.ts | 2 +
.../src/trpc/apis/admin-apis/apis/offers.ts | 138 ++++++++
.../src/trpc/apis/admin-apis/apis/slots.ts | 8 +-
.../src/trpc/apis/common-apis/common.ts | 32 ++
.../src/trpc/apis/user-apis/apis/slots.ts | 52 +--
apps/backend/worker.ts | 2 +
apps/backend/wrangler-commands.md | 6 +
apps/backend/wrangler.dev.toml | 6 +-
packages/db_helper_postgres/index.ts | 10 +
.../src/admin-apis/offers.ts | 162 +++++++++
.../src/admin-apis/slots.ts | 8 +-
packages/db_helper_postgres/src/db/schema.ts | 32 ++
.../db_helper_sqlite/drizzle/0002_offers.sql | 30 ++
.../drizzle/meta/_journal.json | 16 +-
packages/db_helper_sqlite/index.ts | 10 +
.../db_helper_sqlite/src/admin-apis/offers.ts | 167 ++++++++++
.../db_helper_sqlite/src/admin-apis/slots.ts | 8 +-
packages/db_helper_sqlite/src/db/schema.ts | 32 ++
.../src/stores/store-helpers.ts | 2 +
packages/shared/index.ts | 1 +
30 files changed, 1313 insertions(+), 30 deletions(-)
create mode 100644 apps/admin-ui/app/(drawer)/offers/_layout.tsx
create mode 100644 apps/admin-ui/app/(drawer)/offers/add.tsx
create mode 100644 apps/admin-ui/app/(drawer)/offers/edit/[id].tsx
create mode 100644 apps/admin-ui/app/(drawer)/offers/index.tsx
create mode 100644 apps/admin-ui/components/OfferForm.tsx
create mode 100644 apps/backend/src/trpc/apis/admin-apis/apis/offers.ts
create mode 100644 packages/db_helper_postgres/src/admin-apis/offers.ts
create mode 100644 packages/db_helper_sqlite/drizzle/0002_offers.sql
create mode 100644 packages/db_helper_sqlite/src/admin-apis/offers.ts
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]