✅ product admin crud
This commit is contained in:
@@ -3,16 +3,17 @@
|
|||||||
import * as Sidebar from "$lib/components/ui/sidebar/index.js";
|
import * as Sidebar from "$lib/components/ui/sidebar/index.js";
|
||||||
import type { ComponentProps } from "svelte";
|
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 SessionIcon from "~icons/carbon/prompt-session";
|
||||||
|
import HistoryIcon from "~icons/iconamoon/history-light";
|
||||||
import BillListIcon from "~icons/solar/bill-list-linear";
|
import BillListIcon from "~icons/solar/bill-list-linear";
|
||||||
import PackageIcon from "~icons/solar/box-broken";
|
import PackageIcon from "~icons/solar/box-broken";
|
||||||
import DashboardIcon from "~icons/solar/laptop-minimalistic-broken";
|
import DashboardIcon from "~icons/solar/laptop-minimalistic-broken";
|
||||||
import UsersIcon from "~icons/solar/users-group-two-rounded-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 = [
|
const mainLinks = [
|
||||||
{
|
{
|
||||||
@@ -40,6 +41,12 @@
|
|||||||
title: "Orders",
|
title: "Orders",
|
||||||
url: adminSiteNavMap.orders,
|
url: adminSiteNavMap.orders,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
icon: ProductIcon,
|
||||||
|
title: "Products",
|
||||||
|
url: adminSiteNavMap.products,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
icon: UsersIcon,
|
icon: UsersIcon,
|
||||||
title: "Profile",
|
title: "Profile",
|
||||||
|
|||||||
@@ -16,8 +16,10 @@ export const adminSiteNavMap = {
|
|||||||
history: `${adminBasePath}/sessions/history`,
|
history: `${adminBasePath}/sessions/history`,
|
||||||
},
|
},
|
||||||
data: `${adminBasePath}/data`,
|
data: `${adminBasePath}/data`,
|
||||||
|
products: `${adminBasePath}/products`,
|
||||||
settings: `${adminBasePath}/settings`,
|
settings: `${adminBasePath}/settings`,
|
||||||
profile: `${adminBasePath}/profile`,
|
profile: `${adminBasePath}/profile`,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const PUBLIC_SITE_URL = env.PUBLIC_SITE_URL ?? "https://example.com";
|
export const PUBLIC_SITE_URL = env.PUBLIC_SITE_URL ?? "https://example.com";
|
||||||
|
export const PUBLIC_FRONTEND_URL = env.PUBLIC_FRONTEND_URL ?? "https://eg.io";
|
||||||
|
|||||||
1
apps/admin/src/lib/domains/product/data.ts
Normal file
1
apps/admin/src/lib/domains/product/data.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from "@pkg/logic/domains/product/data";
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<span>Show the product list here</span>
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
<span>Show the product create and details modals here</span>
|
|
||||||
@@ -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();
|
||||||
|
|||||||
1
apps/admin/src/lib/domains/product/repository.ts
Normal file
1
apps/admin/src/lib/domains/product/repository.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from "@pkg/logic/domains/product/repository";
|
||||||
@@ -1,9 +1,47 @@
|
|||||||
import { protectedProcedure } from "$lib/server/trpc/t";
|
import { protectedProcedure } from "$lib/server/trpc/t";
|
||||||
import { createTRPCRouter } from "$lib/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({
|
export const productRouter = createTRPCRouter({
|
||||||
getAllProducts: protectedProcedure.query(async ({}) => {
|
getAllProducts: protectedProcedure.query(async ({}) => {
|
||||||
return {};
|
const controller = getProductUseCases();
|
||||||
|
return controller.getAllProducts();
|
||||||
|
}),
|
||||||
|
|
||||||
|
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);
|
||||||
}),
|
}),
|
||||||
// TODO: complete the implementation of this
|
|
||||||
});
|
});
|
||||||
|
|||||||
1
apps/admin/src/lib/domains/product/usecases.ts
Normal file
1
apps/admin/src/lib/domains/product/usecases.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from "@pkg/logic/domains/product/usecases";
|
||||||
111
apps/admin/src/lib/domains/product/view/product-form.svelte
Normal file
111
apps/admin/src/lib/domains/product/view/product-form.svelte
Normal 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>
|
||||||
286
apps/admin/src/lib/domains/product/view/product-list.svelte
Normal file
286
apps/admin/src/lib/domains/product/view/product-list.svelte
Normal 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>
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import { env } from "$env/dynamic/private";
|
|
||||||
import { TicketRepository } from "$lib/domains/ticket/data/repository";
|
import { TicketRepository } from "$lib/domains/ticket/data/repository";
|
||||||
import { db } from "@pkg/db";
|
import { db } from "@pkg/db";
|
||||||
import { Logger } from "@pkg/logger";
|
import { Logger } from "@pkg/logger";
|
||||||
@@ -50,9 +49,6 @@ export class TicketController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getTC() {
|
export function getTC() {
|
||||||
const ds = new ScrapedTicketsDataSource(
|
const ds = new ScrapedTicketsDataSource("", "");
|
||||||
env.TICKET_SCRAPER_API_URL,
|
|
||||||
env.API_KEY,
|
|
||||||
);
|
|
||||||
return new TicketController(new TicketRepository(db, ds));
|
return new TicketController(new TicketRepository(db, ds));
|
||||||
}
|
}
|
||||||
|
|||||||
10
apps/admin/src/routes/(main)/products/+page.svelte
Normal file
10
apps/admin/src/routes/(main)/products/+page.svelte
Normal 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>
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import { env } from "$env/dynamic/private";
|
|
||||||
import { convertAndFormatCurrency } from "$lib/domains/currency/view/currency.vm.svelte";
|
import { convertAndFormatCurrency } from "$lib/domains/currency/view/currency.vm.svelte";
|
||||||
import { db } from "@pkg/db";
|
import { db } from "@pkg/db";
|
||||||
import { getError, Logger } from "@pkg/logger";
|
import { getError, Logger } from "@pkg/logger";
|
||||||
@@ -149,9 +148,6 @@ export class TicketController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getTC() {
|
export function getTC() {
|
||||||
const ds = new ScrapedTicketsDataSource(
|
const ds = new ScrapedTicketsDataSource("", "");
|
||||||
env.TICKET_SCRAPER_API_URL,
|
|
||||||
env.API_KEY,
|
|
||||||
);
|
|
||||||
return new TicketController(new TicketRepository(db, ds));
|
return new TicketController(new TicketRepository(db, ds));
|
||||||
}
|
}
|
||||||
|
|||||||
2
packages/db/migrations/0007_true_garia.sql
Normal file
2
packages/db/migrations/0007_true_garia.sql
Normal 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");
|
||||||
1575
packages/db/migrations/meta/0007_snapshot.json
Normal file
1575
packages/db/migrations/meta/0007_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -50,6 +50,13 @@
|
|||||||
"when": 1760975763245,
|
"when": 1760975763245,
|
||||||
"tag": "0006_puzzling_avengers",
|
"tag": "0006_puzzling_avengers",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 7,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1760976587011,
|
||||||
|
"tag": "0007_true_garia",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -62,6 +62,7 @@ export const order = pgTable("order", {
|
|||||||
|
|
||||||
export const product = pgTable("product", {
|
export const product = pgTable("product", {
|
||||||
id: serial("id").primaryKey(),
|
id: serial("id").primaryKey(),
|
||||||
|
linkId: varchar("link_id", { length: 32 }).notNull().unique(),
|
||||||
title: varchar("title", { length: 64 }).notNull(),
|
title: varchar("title", { length: 64 }).notNull(),
|
||||||
description: text("description").notNull(),
|
description: text("description").notNull(),
|
||||||
longDescription: text("long_description").notNull(),
|
longDescription: text("long_description").notNull(),
|
||||||
|
|||||||
@@ -1,14 +1,37 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
export const productModel = z.object({
|
export const productModel = z.object({
|
||||||
id: z.string(),
|
id: z.number().optional(),
|
||||||
linkId: z.string(),
|
linkId: z.string().min(8).max(32),
|
||||||
title: z.string(),
|
title: z.string().min(1).max(64),
|
||||||
description: z.string(),
|
description: z.string().min(1),
|
||||||
longDescription: z.string(),
|
longDescription: z.string().min(1),
|
||||||
price: z.number(),
|
price: z.coerce.number().nonnegative(),
|
||||||
discountPrice: z.number(),
|
discountPrice: z.coerce.number().nonnegative(),
|
||||||
createdAt: z.coerce.string().optional(),
|
createdAt: z.coerce.string().optional(),
|
||||||
updatedAt: z.coerce.string().optional(),
|
updatedAt: z.coerce.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type ProductModel = z.infer<typeof productModel>;
|
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>;
|
||||||
|
|||||||
@@ -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 {
|
export class ProductRepository {
|
||||||
private db: Database;
|
private db: Database;
|
||||||
@@ -7,9 +17,277 @@ export class ProductRepository {
|
|||||||
this.db = db;
|
this.db = db;
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: compelte the crud method implementation
|
async listAllProducts(): Promise<Result<ProductModel[]>> {
|
||||||
async listAllProducts() {}
|
try {
|
||||||
async createProduct() {}
|
const results = await this.db.query.product.findMany({
|
||||||
async updateProduct() {}
|
orderBy: [desc(product.createdAt)],
|
||||||
async deleteProduct() {}
|
});
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
39
packages/logic/domains/product/usecases.ts
Normal file
39
packages/logic/domains/product/usecases.ts
Normal 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));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user