product admin crud

This commit is contained in:
user
2025-10-20 19:16:19 +03:00
parent e239b3bbf6
commit 2cc0ca4c51
22 changed files with 2601 additions and 35 deletions

View File

@@ -3,16 +3,17 @@
import * as Sidebar from "$lib/components/ui/sidebar/index.js";
import type { ComponentProps } from "svelte";
import Icon from "$lib/components/atoms/icon.svelte";
import { adminSiteNavMap } from "$lib/core/constants";
import { sessionUserInfo } from "$lib/stores/session.info";
import { SettingsIcon } from "@lucide/svelte";
import ProductIcon from "~icons/carbon/carbon-for-ibm-product";
import SessionIcon from "~icons/carbon/prompt-session";
import HistoryIcon from "~icons/iconamoon/history-light";
import BillListIcon from "~icons/solar/bill-list-linear";
import PackageIcon from "~icons/solar/box-broken";
import DashboardIcon from "~icons/solar/laptop-minimalistic-broken";
import UsersIcon from "~icons/solar/users-group-two-rounded-broken";
import HistoryIcon from "~icons/iconamoon/history-light";
import { adminSiteNavMap } from "$lib/core/constants";
import Icon from "$lib/components/atoms/icon.svelte";
import { sessionUserInfo } from "$lib/stores/session.info";
import { SettingsIcon } from "@lucide/svelte";
const mainLinks = [
{
@@ -40,6 +41,12 @@
title: "Orders",
url: adminSiteNavMap.orders,
},
{
icon: ProductIcon,
title: "Products",
url: adminSiteNavMap.products,
},
{
icon: UsersIcon,
title: "Profile",

View File

@@ -16,8 +16,10 @@ export const adminSiteNavMap = {
history: `${adminBasePath}/sessions/history`,
},
data: `${adminBasePath}/data`,
products: `${adminBasePath}/products`,
settings: `${adminBasePath}/settings`,
profile: `${adminBasePath}/profile`,
};
export const PUBLIC_SITE_URL = env.PUBLIC_SITE_URL ?? "https://example.com";
export const PUBLIC_FRONTEND_URL = env.PUBLIC_FRONTEND_URL ?? "https://eg.io";

View File

@@ -0,0 +1 @@
export * from "@pkg/logic/domains/product/data";

View File

@@ -1,4 +0,0 @@
<script lang="ts">
</script>
<span>Show the product list here</span>

View File

@@ -1 +0,0 @@
<span>Show the product create and details modals here</span>

View File

@@ -0,0 +1,197 @@
import { PUBLIC_FRONTEND_URL } from "$lib/core/constants";
import { trpcApiStore } from "$lib/stores/api";
import { toast } from "svelte-sonner";
import { get } from "svelte/store";
import type {
CreateProductPayload,
ProductModel,
UpdateProductPayload,
} from "./data";
export class ProductViewModel {
products = $state<ProductModel[]>([]);
loading = $state(false);
formLoading = $state(false);
// Selected product for edit/delete operations
selectedProduct = $state<ProductModel | null>(null);
async fetchProducts() {
const api = get(trpcApiStore);
if (!api) {
return toast.error("API client not initialized");
}
this.loading = true;
try {
const result = await api.product.getAllProducts.query();
if (result.error) {
toast.error(result.error.message, {
description: result.error.userHint,
});
return false;
}
this.products = result.data || [];
return true;
} catch (e) {
console.error(e);
toast.error("Failed to fetch products", {
description: "Please try again later",
});
return false;
} finally {
this.loading = false;
}
}
async createProduct(payload: CreateProductPayload) {
const api = get(trpcApiStore);
if (!api) {
return toast.error("API client not initialized");
}
this.formLoading = true;
try {
const result = await api.product.createProduct.mutate(payload);
if (result.error) {
toast.error(result.error.message, {
description: result.error.userHint,
});
return false;
}
toast.success("Product created successfully");
await this.fetchProducts();
return true;
} catch (e) {
toast.error("Failed to create product", {
description: "Please try again later",
});
return false;
} finally {
this.formLoading = false;
}
}
async updateProduct(payload: UpdateProductPayload) {
const api = get(trpcApiStore);
if (!api) {
return toast.error("API client not initialized");
}
this.formLoading = true;
try {
const result = await api.product.updateProduct.mutate(payload);
if (result.error) {
toast.error(result.error.message, {
description: result.error.userHint,
});
return false;
}
toast.success("Product updated successfully");
await this.fetchProducts();
return true;
} catch (e) {
toast.error("Failed to update product", {
description: "Please try again later",
});
return false;
} finally {
this.formLoading = false;
this.selectedProduct = null;
}
}
async deleteProduct(id: number) {
const api = get(trpcApiStore);
if (!api) {
return toast.error("API client not initialized");
}
this.formLoading = true;
try {
const result = await api.product.deleteProduct.mutate({ id });
if (result.error) {
toast.error(result.error.message, {
description: result.error.userHint,
});
return false;
}
toast.success("Product deleted successfully");
await this.fetchProducts();
return true;
} catch (e) {
toast.error("Failed to delete product", {
description: "Please try again later",
});
return false;
} finally {
this.formLoading = false;
this.selectedProduct = null;
}
}
async refreshProductLinkId(id: number) {
const api = get(trpcApiStore);
if (!api) {
return toast.error("API client not initialized");
}
try {
const result = await api.product.refreshProductLinkId.mutate({ id });
if (result.error) {
toast.error(result.error.message, {
description: result.error.userHint,
});
return null;
}
toast.success("Link ID refreshed successfully");
await this.fetchProducts();
return result.data;
} catch (e) {
toast.error("Failed to refresh link ID", {
description: "Please try again later",
});
return null;
}
}
selectProduct(product: ProductModel) {
this.selectedProduct = { ...product };
}
clearSelectedProduct() {
this.selectedProduct = null;
}
getDefaultProduct(): CreateProductPayload {
return {
title: "",
description: "",
longDescription: "",
price: 0,
discountPrice: 0,
};
}
async copyLinkToClipboard(linkId: string) {
try {
await navigator.clipboard.writeText(
`${PUBLIC_FRONTEND_URL}/${linkId}`,
);
toast.success("Frontend link copied to clipboard");
return true;
} catch (e) {
toast.error("Failed to copy link ID", {
description: "Please try copying manually",
});
return false;
}
}
}
export const productVM = new ProductViewModel();

View File

@@ -0,0 +1 @@
export * from "@pkg/logic/domains/product/repository";

View File

@@ -1,9 +1,47 @@
import { protectedProcedure } from "$lib/server/trpc/t";
import { createTRPCRouter } from "$lib/trpc/t";
import { z } from "zod";
import { createProductPayload, updateProductPayload } from "./data";
import { getProductUseCases } from "./usecases";
export const productRouter = createTRPCRouter({
getAllProducts: protectedProcedure.query(async ({}) => {
return {};
const controller = getProductUseCases();
return controller.getAllProducts();
}),
// TODO: complete the implementation of this
getProductById: protectedProcedure
.input(z.object({ id: z.number() }))
.query(async ({ input }) => {
const controller = getProductUseCases();
return controller.getProductById(input.id);
}),
createProduct: protectedProcedure
.input(createProductPayload)
.mutation(async ({ input }) => {
const controller = getProductUseCases();
return controller.createProduct(input);
}),
updateProduct: protectedProcedure
.input(updateProductPayload)
.mutation(async ({ input }) => {
const controller = getProductUseCases();
return controller.updateProduct(input);
}),
deleteProduct: protectedProcedure
.input(z.object({ id: z.number() }))
.mutation(async ({ input }) => {
const controller = getProductUseCases();
return controller.deleteProduct(input.id);
}),
refreshProductLinkId: protectedProcedure
.input(z.object({ id: z.number() }))
.mutation(async ({ input }) => {
const controller = getProductUseCases();
return controller.refreshProductLinkId(input.id);
}),
});

View File

@@ -0,0 +1 @@
export * from "@pkg/logic/domains/product/usecases";

View File

@@ -0,0 +1,111 @@
<script lang="ts">
import Button from "$lib/components/ui/button/button.svelte";
import Input from "$lib/components/ui/input/input.svelte";
import { Label } from "$lib/components/ui/label";
import { Textarea } from "$lib/components/ui/textarea";
import type {
CreateProductPayload,
UpdateProductPayload,
} from "$lib/domains/product/data";
export let formData: CreateProductPayload | UpdateProductPayload;
export let loading = false;
export let onSubmit: () => void;
export let onCancel: () => void;
const isNewProduct = !("id" in formData);
</script>
<form on:submit|preventDefault={onSubmit} class="space-y-6">
<!-- Title -->
<div class="space-y-2">
<Label for="title">Product Title</Label>
<Input
id="title"
bind:value={formData.title}
placeholder="e.g. Premium Flight Package"
maxlength={64}
required
/>
</div>
<!-- Description -->
<div class="space-y-2">
<Label for="description">Short Description</Label>
<Textarea
id="description"
bind:value={formData.description}
placeholder="Brief description of the product"
rows={3}
required
/>
</div>
<!-- Long Description -->
<div class="space-y-2">
<Label for="longDescription">Long Description</Label>
<Textarea
id="longDescription"
bind:value={formData.longDescription}
placeholder="Detailed description of the product"
rows={6}
required
/>
</div>
<div class="grid grid-cols-2 gap-4">
<!-- Price -->
<div class="space-y-2">
<Label for="price">Price</Label>
<div class="relative">
<Input
id="price"
type="number"
min={0}
step="0.01"
bind:value={formData.price}
required
/>
<div
class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3"
>
<span class="text-gray-500">$</span>
</div>
</div>
</div>
<!-- Discount Price -->
<div class="space-y-2">
<Label for="discountPrice">Discount Price</Label>
<div class="relative">
<Input
id="discountPrice"
type="number"
min={0}
step="0.01"
bind:value={formData.discountPrice}
required
/>
<div
class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3"
>
<span class="text-gray-500">$</span>
</div>
</div>
</div>
</div>
<!-- Action Buttons -->
<div class="flex justify-end space-x-2">
<Button variant="outline" onclick={onCancel} disabled={loading}>
Cancel
</Button>
<Button type="submit" disabled={loading}>
{#if loading}
Processing...
{:else}
{isNewProduct ? "Create Product" : "Update Product"}
{/if}
</Button>
</div>
</form>

View File

@@ -0,0 +1,286 @@
<script lang="ts">
import Loader from "$lib/components/atoms/loader.svelte";
import Title from "$lib/components/atoms/title.svelte";
import * as AlertDialog from "$lib/components/ui/alert-dialog/index";
import { Badge } from "$lib/components/ui/badge";
import Button from "$lib/components/ui/button/button.svelte";
import * as Dialog from "$lib/components/ui/dialog";
import { productVM } from "$lib/domains/product/product.vm.svelte";
import { Copy, Pencil, PlusIcon, RefreshCw, Trash2 } from "@lucide/svelte";
import { onMount } from "svelte";
import type { CreateProductPayload } from "../data";
import ProductForm from "./product-form.svelte";
let createDialogOpen = $state(false);
let editDialogOpen = $state(false);
let deleteDialogOpen = $state(false);
let newProductData = $state<CreateProductPayload>(
productVM.getDefaultProduct(),
);
async function handleCreateProduct() {
const success = await productVM.createProduct(newProductData);
if (success) {
createDialogOpen = false;
newProductData = productVM.getDefaultProduct();
}
}
async function handleUpdateProduct() {
if (!productVM.selectedProduct) return;
const success = await productVM.updateProduct({
id: productVM.selectedProduct.id! ?? -1,
...productVM.selectedProduct,
});
if (success) {
editDialogOpen = false;
}
}
async function handleDeleteProduct() {
if (!productVM.selectedProduct) return;
const success = await productVM.deleteProduct(
productVM.selectedProduct.id!,
);
if (success) {
deleteDialogOpen = false;
}
}
async function handleRefreshLinkId(id: number) {
await productVM.refreshProductLinkId(id);
}
async function handleCopyLink(linkId: string) {
await productVM.copyLinkToClipboard(linkId);
}
function formatPrice(price: number) {
return `$${price.toFixed(2)}`;
}
onMount(() => {
setTimeout(async () => {
productVM.fetchProducts();
}, 1000);
});
</script>
<div class="mb-6 flex items-center justify-between">
<Title size="h3" weight="semibold">Products</Title>
<Button onclick={() => (createDialogOpen = true)}>
<PlusIcon class="mr-2 h-4 w-4" />
New Product
</Button>
</div>
{#if productVM.loading}
<div class="flex items-center justify-center py-20">
<Loader />
</div>
{:else if productVM.products.length === 0}
<div class="rounded-lg border py-10 text-center">
<p class="text-gray-500">
No products found. Create your first product to get started.
</p>
</div>
{:else}
<div class="overflow-x-auto">
<table class="w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
>Product</th
>
<th
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
>Pricing</th
>
<th
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
>Link ID</th
>
<th
class="px-6 py-3 text-right text-xs font-medium uppercase tracking-wider text-gray-500"
>Actions</th
>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white">
{#each productVM.products as product}
<tr>
<td class="whitespace-nowrap px-6 py-4">
<div class="font-medium text-gray-900">
{product.title}
</div>
<div class="text-sm text-gray-500">
{product.description}
</div>
</td>
<td class="whitespace-nowrap px-6 py-4">
<div class="flex flex-col gap-1">
<div class="text-sm">
<span class="text-gray-500">Price:</span>
<span class="ml-1 font-medium"
>{formatPrice(product.price)}</span
>
</div>
{#if product.discountPrice > 0 && product.discountPrice < product.price}
<Badge variant="success" class="w-fit">
Discount: {formatPrice(
product.discountPrice,
)}
</Badge>
{/if}
</div>
</td>
<td class="px-6 py-4">
<div class="flex items-center gap-2">
<code
class="rounded bg-gray-100 px-2 py-1 font-mono text-xs"
>
{product.linkId}
</code>
<Button
variant="ghost"
size="sm"
onclick={() => handleCopyLink(product.linkId)}
>
<Copy class="h-3 w-3" />
</Button>
</div>
</td>
<td
class="flex w-full items-center justify-end gap-2 whitespace-nowrap px-6 py-4 text-right"
>
<Button
variant="ghost"
size="sm"
onclick={() => handleRefreshLinkId(product.id!)}
title="Refresh Link ID"
>
<RefreshCw class="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onclick={() => {
productVM.selectProduct(product);
editDialogOpen = true;
}}
>
<Pencil class="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onclick={() => {
productVM.selectProduct(product);
deleteDialogOpen = true;
}}
>
<Trash2 class="h-4 w-4" />
</Button>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
<!-- Create Product Dialog -->
<Dialog.Root bind:open={createDialogOpen}>
<Dialog.Content class="max-w-2xl">
<Dialog.Header>
<Dialog.Title>Create New Product</Dialog.Title>
<Dialog.Description>
Create a new product for your catalog.
</Dialog.Description>
</Dialog.Header>
<ProductForm
formData={newProductData}
loading={productVM.formLoading}
onSubmit={handleCreateProduct}
onCancel={() => (createDialogOpen = false)}
/>
</Dialog.Content>
</Dialog.Root>
<!-- Edit Product Dialog -->
<Dialog.Root bind:open={editDialogOpen}>
<Dialog.Content class="max-w-2xl">
<Dialog.Header>
<Dialog.Title>Edit Product</Dialog.Title>
<Dialog.Description>Update this product's details.</Dialog.Description
>
</Dialog.Header>
{#if productVM.selectedProduct}
<ProductForm
formData={productVM.selectedProduct}
loading={productVM.formLoading}
onSubmit={handleUpdateProduct}
onCancel={() => (editDialogOpen = false)}
/>
{/if}
</Dialog.Content>
</Dialog.Root>
<!-- Delete Confirmation Dialog -->
<AlertDialog.Root bind:open={deleteDialogOpen}>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>Delete Product</AlertDialog.Title>
<AlertDialog.Description>
Are you sure you want to delete this product? This action cannot
be undone.
</AlertDialog.Description>
</AlertDialog.Header>
{#if productVM.selectedProduct}
<div class="mb-4 rounded-md bg-gray-100 p-4">
<div class="font-bold">{productVM.selectedProduct.title}</div>
<div class="text-sm text-gray-600">
{productVM.selectedProduct.description}
</div>
<div class="mt-2 flex items-center gap-2">
<Badge
>Price: {formatPrice(
productVM.selectedProduct.price,
)}</Badge
>
{#if productVM.selectedProduct.discountPrice > 0}
<Badge variant="success"
>Discount: {formatPrice(
productVM.selectedProduct.discountPrice,
)}</Badge
>
{/if}
</div>
</div>
{/if}
<AlertDialog.Footer>
<AlertDialog.Cancel onclick={() => (deleteDialogOpen = false)}>
Cancel
</AlertDialog.Cancel>
<AlertDialog.Action
onclick={handleDeleteProduct}
disabled={productVM.formLoading}
>
{productVM.formLoading ? "Deleting..." : "Delete"}
</AlertDialog.Action>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>

View File

@@ -1,4 +1,3 @@
import { env } from "$env/dynamic/private";
import { TicketRepository } from "$lib/domains/ticket/data/repository";
import { db } from "@pkg/db";
import { Logger } from "@pkg/logger";
@@ -50,9 +49,6 @@ export class TicketController {
}
export function getTC() {
const ds = new ScrapedTicketsDataSource(
env.TICKET_SCRAPER_API_URL,
env.API_KEY,
);
const ds = new ScrapedTicketsDataSource("", "");
return new TicketController(new TicketRepository(db, ds));
}

View File

@@ -0,0 +1,10 @@
<script lang="ts">
import ProductList from "$lib/domains/product/view/product-list.svelte";
import { pageTitle } from "$lib/hooks/page-title.svelte";
pageTitle.set("Products");
</script>
<div class="container mx-auto py-8">
<ProductList />
</div>

View File

@@ -1,4 +1,3 @@
import { env } from "$env/dynamic/private";
import { convertAndFormatCurrency } from "$lib/domains/currency/view/currency.vm.svelte";
import { db } from "@pkg/db";
import { getError, Logger } from "@pkg/logger";
@@ -149,9 +148,6 @@ export class TicketController {
}
export function getTC() {
const ds = new ScrapedTicketsDataSource(
env.TICKET_SCRAPER_API_URL,
env.API_KEY,
);
const ds = new ScrapedTicketsDataSource("", "");
return new TicketController(new TicketRepository(db, ds));
}

View File

@@ -0,0 +1,2 @@
ALTER TABLE "product" ADD COLUMN "link_id" varchar(32) NOT NULL;--> statement-breakpoint
ALTER TABLE "product" ADD CONSTRAINT "product_link_id_unique" UNIQUE("link_id");

File diff suppressed because it is too large Load Diff

View File

@@ -50,6 +50,13 @@
"when": 1760975763245,
"tag": "0006_puzzling_avengers",
"breakpoints": true
},
{
"idx": 7,
"version": "7",
"when": 1760976587011,
"tag": "0007_true_garia",
"breakpoints": true
}
]
}

View File

@@ -62,6 +62,7 @@ export const order = pgTable("order", {
export const product = pgTable("product", {
id: serial("id").primaryKey(),
linkId: varchar("link_id", { length: 32 }).notNull().unique(),
title: varchar("title", { length: 64 }).notNull(),
description: text("description").notNull(),
longDescription: text("long_description").notNull(),

View File

@@ -1,14 +1,37 @@
import { z } from "zod";
export const productModel = z.object({
id: z.string(),
linkId: z.string(),
title: z.string(),
description: z.string(),
longDescription: z.string(),
price: z.number(),
discountPrice: z.number(),
id: z.number().optional(),
linkId: z.string().min(8).max(32),
title: z.string().min(1).max(64),
description: z.string().min(1),
longDescription: z.string().min(1),
price: z.coerce.number().nonnegative(),
discountPrice: z.coerce.number().nonnegative(),
createdAt: z.coerce.string().optional(),
updatedAt: z.coerce.string().optional(),
});
export type ProductModel = z.infer<typeof productModel>;
export const createProductPayload = productModel.omit({
id: true,
linkId: true,
createdAt: true,
updatedAt: true,
});
export type CreateProductPayload = z.infer<typeof createProductPayload>;
export const updateProductPayload = productModel
.omit({
linkId: true,
createdAt: true,
updatedAt: true,
})
.partial()
.extend({
id: z.number(),
});
export type UpdateProductPayload = z.infer<typeof updateProductPayload>;

View File

@@ -1,4 +1,14 @@
import { Database } from "@pkg/db";
import { desc, eq, type Database } from "@pkg/db";
import { product } from "@pkg/db/schema";
import { getError, Logger } from "@pkg/logger";
import { ERROR_CODES, type Result } from "@pkg/result";
import { nanoid } from "nanoid";
import {
productModel,
type CreateProductPayload,
type ProductModel,
type UpdateProductPayload,
} from "./data";
export class ProductRepository {
private db: Database;
@@ -7,9 +17,277 @@ export class ProductRepository {
this.db = db;
}
// TODO: compelte the crud method implementation
async listAllProducts() {}
async createProduct() {}
async updateProduct() {}
async deleteProduct() {}
async listAllProducts(): Promise<Result<ProductModel[]>> {
try {
const results = await this.db.query.product.findMany({
orderBy: [desc(product.createdAt)],
});
const out = [] as ProductModel[];
for (const result of results) {
const parsed = productModel.safeParse(result);
if (!parsed.success) {
Logger.error("Failed to parse product");
Logger.error(parsed.error);
continue;
}
out.push(parsed.data);
}
return { data: out };
} catch (e) {
return {
error: getError(
{
code: ERROR_CODES.DATABASE_ERROR,
message: "Failed to fetch products",
detail:
"An error occurred while retrieving products from the database",
userHint: "Please try refreshing the page",
actionable: false,
},
e,
),
};
}
}
async getProductById(id: number): Promise<Result<ProductModel>> {
try {
const result = await this.db.query.product.findFirst({
where: eq(product.id, id),
});
if (!result) {
return {
error: getError({
code: ERROR_CODES.NOT_FOUND_ERROR,
message: "Product not found",
detail: "No product exists with the provided ID",
userHint: "Please check the product ID and try again",
actionable: true,
}),
};
}
const parsed = productModel.safeParse(result);
if (!parsed.success) {
Logger.error("Failed to parse product", result);
return {
error: getError({
code: ERROR_CODES.INTERNAL_SERVER_ERROR,
message: "Failed to parse product",
userHint: "Please try again",
detail: "Failed to parse product",
}),
};
}
return { data: parsed.data };
} catch (e) {
return {
error: getError(
{
code: ERROR_CODES.DATABASE_ERROR,
message: "Failed to fetch product",
detail:
"An error occurred while retrieving the product from the database",
userHint: "Please try refreshing the page",
actionable: false,
},
e,
),
};
}
}
async createProduct(payload: CreateProductPayload): Promise<Result<number>> {
try {
// Generate a unique linkId
const linkId = nanoid(16);
const result = await this.db
.insert(product)
.values({
linkId,
title: payload.title,
description: payload.description,
longDescription: payload.longDescription,
price: payload.price.toString(),
discountPrice: payload.discountPrice.toString(),
})
.returning({ id: product.id })
.execute();
if (!result || result.length === 0) {
throw new Error("Failed to create product record");
}
return { data: result[0].id };
} catch (e) {
return {
error: getError(
{
code: ERROR_CODES.DATABASE_ERROR,
message: "Failed to create product",
detail: "An error occurred while creating the product",
userHint: "Please try again",
actionable: false,
},
e,
),
};
}
}
async updateProduct(payload: UpdateProductPayload): Promise<Result<boolean>> {
try {
if (!payload.id) {
return {
error: getError({
code: ERROR_CODES.VALIDATION_ERROR,
message: "Invalid product ID",
detail: "No product ID was provided for the update operation",
userHint: "Please provide a valid product ID",
actionable: true,
}),
};
}
// Check if product exists
const existing = await this.db.query.product.findFirst({
where: eq(product.id, payload.id),
});
if (!existing) {
return {
error: getError({
code: ERROR_CODES.NOT_FOUND_ERROR,
message: "Product not found",
detail: "No product exists with the provided ID",
userHint: "Please check the product ID and try again",
actionable: true,
}),
};
}
// Build the update object with only the fields that are provided
const updateValues: Record<string, any> = {};
if (payload.title !== undefined) updateValues.title = payload.title;
if (payload.description !== undefined)
updateValues.description = payload.description;
if (payload.longDescription !== undefined)
updateValues.longDescription = payload.longDescription;
if (payload.price !== undefined)
updateValues.price = payload.price.toString();
if (payload.discountPrice !== undefined)
updateValues.discountPrice = payload.discountPrice.toString();
updateValues.updatedAt = new Date();
await this.db
.update(product)
.set(updateValues)
.where(eq(product.id, payload.id))
.execute();
return { data: true };
} catch (e) {
return {
error: getError(
{
code: ERROR_CODES.DATABASE_ERROR,
message: "Failed to update product",
detail: "An error occurred while updating the product",
userHint: "Please try again",
actionable: false,
},
e,
),
};
}
}
async deleteProduct(id: number): Promise<Result<boolean>> {
try {
// Check if product exists
const existing = await this.db.query.product.findFirst({
where: eq(product.id, id),
});
if (!existing) {
return {
error: getError({
code: ERROR_CODES.NOT_FOUND_ERROR,
message: "Product not found",
detail: "No product exists with the provided ID",
userHint: "Please check the product ID and try again",
actionable: true,
}),
};
}
await this.db.delete(product).where(eq(product.id, id)).execute();
return { data: true };
} catch (e) {
return {
error: getError(
{
code: ERROR_CODES.DATABASE_ERROR,
message: "Failed to delete product",
detail: "An error occurred while deleting the product",
userHint: "Please try again",
actionable: false,
},
e,
),
};
}
}
async refreshProductLinkId(id: number): Promise<Result<string>> {
try {
// Check if product exists
const existing = await this.db.query.product.findFirst({
where: eq(product.id, id),
});
if (!existing) {
return {
error: getError({
code: ERROR_CODES.NOT_FOUND_ERROR,
message: "Product not found",
detail: "No product exists with the provided ID",
userHint: "Please check the product ID and try again",
actionable: true,
}),
};
}
// Generate a new unique linkId
const newLinkId = nanoid(16);
await this.db
.update(product)
.set({
linkId: newLinkId,
updatedAt: new Date(),
})
.where(eq(product.id, id))
.execute();
return { data: newLinkId };
} catch (e) {
return {
error: getError(
{
code: ERROR_CODES.DATABASE_ERROR,
message: "Failed to refresh product link ID",
detail: "An error occurred while refreshing the product's link ID",
userHint: "Please try again",
actionable: false,
},
e,
),
};
}
}
}

View File

@@ -0,0 +1,39 @@
import { db } from "@pkg/db";
import type { CreateProductPayload, UpdateProductPayload } from "./data";
import { ProductRepository } from "./repository";
export class ProductUseCases {
private repo: ProductRepository;
constructor(repo: ProductRepository) {
this.repo = repo;
}
async getAllProducts() {
return this.repo.listAllProducts();
}
async getProductById(id: number) {
return this.repo.getProductById(id);
}
async createProduct(payload: CreateProductPayload) {
return this.repo.createProduct(payload);
}
async updateProduct(payload: UpdateProductPayload) {
return this.repo.updateProduct(payload);
}
async deleteProduct(id: number) {
return this.repo.deleteProduct(id);
}
async refreshProductLinkId(id: number) {
return this.repo.refreshProductLinkId(id);
}
}
export function getProductUseCases() {
return new ProductUseCases(new ProductRepository(db));
}