robodev 0.22.0 → 0.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/emit-starter.test.ts +36 -0
- package/templates/auth-chat/package.json +1 -1
- package/templates/backend/api/_lib.ts +9 -2
- package/templates/backend/package.json +1 -1
- package/templates/catalog.json +5 -0
- package/templates/empty/package.json +1 -1
- package/templates/marketplace/.config/local.spa.template.yml +27 -0
- package/templates/marketplace/.oxlint-base.json +29 -0
- package/templates/marketplace/.oxlintrc.json +78 -0
- package/templates/marketplace/.rulesync/rules/frontend-api-boundary.md +36 -0
- package/templates/marketplace/.rulesync/rules/frontend-component-structure.md +38 -0
- package/templates/marketplace/.rulesync/rules/frontend-forms.md +32 -0
- package/templates/marketplace/.rulesync/rules/frontend-notifications.md +24 -0
- package/templates/marketplace/.rulesync/rules/frontend-povio-components.md +42 -0
- package/templates/marketplace/.rulesync/rules/frontend-povio-ui.md +45 -0
- package/templates/marketplace/.rulesync/rules/frontend-query-autocomplete.md +29 -0
- package/templates/marketplace/.rulesync/rules/frontend-tables-and-lists.md +34 -0
- package/templates/marketplace/.rulesync/rules/frontend-translations.md +26 -0
- package/templates/marketplace/.rulesync/rules/project-overview.md +32 -0
- package/templates/marketplace/.rulesync/rules/robodev-api.md +30 -0
- package/templates/marketplace/.rulesync/rules/robodev-auth.md +40 -0
- package/templates/marketplace/.rulesync/rules/robodev-database.md +22 -0
- package/templates/marketplace/.rulesync/rules/role-based-app-structure.md +36 -0
- package/templates/marketplace/.rulesync/skills/media-feature/SKILL.md +21 -0
- package/templates/marketplace/.rulesync/skills/povio-ui-styling/SKILL.md +208 -0
- package/templates/marketplace/.rulesync/skills/povio-ui-styling/agents/openai.yaml +4 -0
- package/templates/marketplace/.rulesync/skills/robodev-api-route/SKILL.md +13 -0
- package/templates/marketplace/.rulesync/skills/robodev-table/SKILL.md +12 -0
- package/templates/marketplace/README.md +40 -0
- package/templates/marketplace/api/_lib.ts +677 -0
- package/templates/marketplace/api/cart/[id].ts +51 -0
- package/templates/marketplace/api/cart.ts +70 -0
- package/templates/marketplace/api/categories.ts +11 -0
- package/templates/marketplace/api/checkout.ts +157 -0
- package/templates/marketplace/api/health.ts +7 -0
- package/templates/marketplace/api/listings/[id].ts +101 -0
- package/templates/marketplace/api/listings/paginate.ts +29 -0
- package/templates/marketplace/api/listings.ts +90 -0
- package/templates/marketplace/api/me.ts +14 -0
- package/templates/marketplace/api/orders/[id]/complete.ts +27 -0
- package/templates/marketplace/api/orders/[id]/ship.ts +24 -0
- package/templates/marketplace/api/orders/[id].ts +15 -0
- package/templates/marketplace/api/purchases.ts +19 -0
- package/templates/marketplace/api/reviews.ts +58 -0
- package/templates/marketplace/api/sales.ts +19 -0
- package/templates/marketplace/api/watches/[listingId].ts +18 -0
- package/templates/marketplace/api/watches.ts +51 -0
- package/templates/marketplace/apps/fe/index.html +24 -0
- package/templates/marketplace/apps/fe/openapi-codegen.config.ts +72 -0
- package/templates/marketplace/apps/fe/package.json +84 -0
- package/templates/marketplace/apps/fe/public/apple-touch-icon.png +0 -0
- package/templates/marketplace/apps/fe/public/favicon-96x96.png +0 -0
- package/templates/marketplace/apps/fe/public/favicon.ico +0 -0
- package/templates/marketplace/apps/fe/public/favicon.svg +3 -0
- package/templates/marketplace/apps/fe/public/site.webmanifest +21 -0
- package/templates/marketplace/apps/fe/public/web-app-manifest-192x192.png +0 -0
- package/templates/marketplace/apps/fe/public/web-app-manifest-512x512.png +0 -0
- package/templates/marketplace/apps/fe/src/assets/fonts/GeneralSans-Bold.otf +0 -0
- package/templates/marketplace/apps/fe/src/assets/fonts/GeneralSans-Medium.otf +0 -0
- package/templates/marketplace/apps/fe/src/assets/fonts/GeneralSans-Regular.otf +0 -0
- package/templates/marketplace/apps/fe/src/assets/fonts/GeneralSans-Semibold.otf +0 -0
- package/templates/marketplace/apps/fe/src/assets/locales/en/translation.json +335 -0
- package/templates/marketplace/apps/fe/src/assets/locales/sl/translation.json +335 -0
- package/templates/marketplace/apps/fe/src/clients/app-rest-client.ts +15 -0
- package/templates/marketplace/apps/fe/src/clients/auth-token-store.ts +101 -0
- package/templates/marketplace/apps/fe/src/clients/rest/app-error-handler.ts +31 -0
- package/templates/marketplace/apps/fe/src/clients/rest/interceptors/authorization-header.interceptor.ts +15 -0
- package/templates/marketplace/apps/fe/src/clients/rest/interceptors/refresh-token.interceptor.ts +37 -0
- package/templates/marketplace/apps/fe/src/clients/rest/interceptors/response.interceptor.ts +17 -0
- package/templates/marketplace/apps/fe/src/components/404.tsx +18 -0
- package/templates/marketplace/apps/fe/src/components/features/auth/AuthBrandPanel.tsx +60 -0
- package/templates/marketplace/apps/fe/src/components/features/auth/AuthLayout.tsx +23 -0
- package/templates/marketplace/apps/fe/src/components/features/auth/LoginPage.tsx +111 -0
- package/templates/marketplace/apps/fe/src/components/features/auth/RegisterPage.tsx +143 -0
- package/templates/marketplace/apps/fe/src/components/features/marketplace/BrowsePage.tsx +76 -0
- package/templates/marketplace/apps/fe/src/components/features/marketplace/CartPage.tsx +88 -0
- package/templates/marketplace/apps/fe/src/components/features/marketplace/CheckoutPage.tsx +193 -0
- package/templates/marketplace/apps/fe/src/components/features/marketplace/ListingCard.tsx +50 -0
- package/templates/marketplace/apps/fe/src/components/features/marketplace/ListingDetailsPage.tsx +152 -0
- package/templates/marketplace/apps/fe/src/components/features/marketplace/ListingFormPage.tsx +243 -0
- package/templates/marketplace/apps/fe/src/components/features/marketplace/MyListingsPage.tsx +84 -0
- package/templates/marketplace/apps/fe/src/components/features/marketplace/OrdersPage.tsx +224 -0
- package/templates/marketplace/apps/fe/src/components/features/marketplace/WatchlistPage.tsx +56 -0
- package/templates/marketplace/apps/fe/src/components/features/profile/ProfilePage.tsx +96 -0
- package/templates/marketplace/apps/fe/src/components/googleAnalytics/GoogleAnalytics.tsx +35 -0
- package/templates/marketplace/apps/fe/src/components/layout/AppLayout.tsx +19 -0
- package/templates/marketplace/apps/fe/src/components/layout/app-header/AppHeader.tsx +95 -0
- package/templates/marketplace/apps/fe/src/components/layout/app-header/MobileNavigation.tsx +55 -0
- package/templates/marketplace/apps/fe/src/components/layout/app-header/NavLink.tsx +29 -0
- package/templates/marketplace/apps/fe/src/components/shared/branding/BrandLogo.tsx +13 -0
- package/templates/marketplace/apps/fe/src/components/shared/error/ErrorFallback.tsx +43 -0
- package/templates/marketplace/apps/fe/src/components/shared/error/ErrorText.tsx +20 -0
- package/templates/marketplace/apps/fe/src/components/shared/error/NotFound.tsx +36 -0
- package/templates/marketplace/apps/fe/src/components/shared/forms/RequiredLabel.tsx +21 -0
- package/templates/marketplace/apps/fe/src/components/shared/forms/RowInputWrapper.tsx +50 -0
- package/templates/marketplace/apps/fe/src/components/shared/head/AppHead.tsx +32 -0
- package/templates/marketplace/apps/fe/src/components/shared/head/DefaultAppHead.tsx +34 -0
- package/templates/marketplace/apps/fe/src/components/shared/layout/LoadingState.tsx +9 -0
- package/templates/marketplace/apps/fe/src/components/shared/layout/ThinPageWrapper.tsx +5 -0
- package/templates/marketplace/apps/fe/src/components/shared/page/PageHeader.tsx +69 -0
- package/templates/marketplace/apps/fe/src/components/shared/ui/Card.tsx +60 -0
- package/templates/marketplace/apps/fe/src/components/shared/ui/GoogleLoginButton.tsx +19 -0
- package/templates/marketplace/apps/fe/src/components/shared/ui/RequiredLabel.tsx +22 -0
- package/templates/marketplace/apps/fe/src/components/shared/ui/TableActions.tsx +22 -0
- package/templates/marketplace/apps/fe/src/config/app.config.ts +35 -0
- package/templates/marketplace/apps/fe/src/config/i18n.ts +43 -0
- package/templates/marketplace/apps/fe/src/config/inits/a11y.ts +14 -0
- package/templates/marketplace/apps/fe/src/config/inits/logger.ts +7 -0
- package/templates/marketplace/apps/fe/src/config/inits/sentry.ts +21 -0
- package/templates/marketplace/apps/fe/src/config/jwt.config.ts +2 -0
- package/templates/marketplace/apps/fe/src/config/query.config.ts +20 -0
- package/templates/marketplace/apps/fe/src/hooks/useAuth.ts +5 -0
- package/templates/marketplace/apps/fe/src/main.tsx +52 -0
- package/templates/marketplace/apps/fe/src/pages/(guest)/login.tsx +23 -0
- package/templates/marketplace/apps/fe/src/pages/(guest)/register.tsx +23 -0
- package/templates/marketplace/apps/fe/src/pages/(guest)/route.tsx +18 -0
- package/templates/marketplace/apps/fe/src/pages/(private)/cart.tsx +22 -0
- package/templates/marketplace/apps/fe/src/pages/(private)/checkout.tsx +22 -0
- package/templates/marketplace/apps/fe/src/pages/(private)/index.tsx +22 -0
- package/templates/marketplace/apps/fe/src/pages/(private)/listings/$id/index.tsx +37 -0
- package/templates/marketplace/apps/fe/src/pages/(private)/my-listings.tsx +22 -0
- package/templates/marketplace/apps/fe/src/pages/(private)/orders/$id.tsx +40 -0
- package/templates/marketplace/apps/fe/src/pages/(private)/orders/index.tsx +22 -0
- package/templates/marketplace/apps/fe/src/pages/(private)/profile.tsx +23 -0
- package/templates/marketplace/apps/fe/src/pages/(private)/route.tsx +18 -0
- package/templates/marketplace/apps/fe/src/pages/(private)/sales.tsx +22 -0
- package/templates/marketplace/apps/fe/src/pages/(private)/sell/$id.tsx +44 -0
- package/templates/marketplace/apps/fe/src/pages/(private)/sell/index.tsx +19 -0
- package/templates/marketplace/apps/fe/src/pages/(private)/watchlist.tsx +22 -0
- package/templates/marketplace/apps/fe/src/pages/(public)/auth.tsx +37 -0
- package/templates/marketplace/apps/fe/src/pages/(public)/route.tsx +9 -0
- package/templates/marketplace/apps/fe/src/pages/__root.tsx +164 -0
- package/templates/marketplace/apps/fe/src/providers/AppErrorBoundary.tsx +10 -0
- package/templates/marketplace/apps/fe/src/providers/OpenApiRuntimeProvider.tsx +31 -0
- package/templates/marketplace/apps/fe/src/providers/index.tsx +44 -0
- package/templates/marketplace/apps/fe/src/providers/jwt.provider.tsx +95 -0
- package/templates/marketplace/apps/fe/src/routeTree.gen.ts +435 -0
- package/templates/marketplace/apps/fe/src/styles/base.css +103 -0
- package/templates/marketplace/apps/fe/src/styles/fonts/fonts.tsx +10 -0
- package/templates/marketplace/apps/fe/src/styles/fonts/general-sans.css +31 -0
- package/templates/marketplace/apps/fe/src/styles/globals.css +28 -0
- package/templates/marketplace/apps/fe/src/styles/overrides/defaults/button.override.ts +536 -0
- package/templates/marketplace/apps/fe/src/styles/overrides/defaults/checkbox.override.ts +71 -0
- package/templates/marketplace/apps/fe/src/styles/overrides/defaults/input.override.ts +252 -0
- package/templates/marketplace/apps/fe/src/styles/overrides/defaults/label.override.ts +91 -0
- package/templates/marketplace/apps/fe/src/styles/overrides/defaults/modal.override.ts +57 -0
- package/templates/marketplace/apps/fe/src/styles/overrides/defaults/radio.override.ts +46 -0
- package/templates/marketplace/apps/fe/src/styles/overrides/defaults/table.override.ts +104 -0
- package/templates/marketplace/apps/fe/src/styles/overrides/defaults/tag.override.ts +66 -0
- package/templates/marketplace/apps/fe/src/styles/overrides/defaults/typography.override.ts +115 -0
- package/templates/marketplace/apps/fe/src/styles/overrides/outline.clsx.ts +10 -0
- package/templates/marketplace/apps/fe/src/styles/overrides/uiOverrides.override.ts +60 -0
- package/templates/marketplace/apps/fe/src/styles/theme.css +2177 -0
- package/templates/marketplace/apps/fe/src/types/i18next.d.ts +12 -0
- package/templates/marketplace/apps/fe/src/types/table.d.ts +17 -0
- package/templates/marketplace/apps/fe/src/types/ui.d.ts +26 -0
- package/templates/marketplace/apps/fe/src/types/vite-env.d.ts +20 -0
- package/templates/marketplace/apps/fe/src/utils/date.utils.ts +17 -0
- package/templates/marketplace/apps/fe/src/utils/image-fallback.ts +9 -0
- package/templates/marketplace/apps/fe/src/utils/listing-form.test.ts +69 -0
- package/templates/marketplace/apps/fe/src/utils/listing-form.ts +77 -0
- package/templates/marketplace/apps/fe/src/utils/listing-submit.ts +29 -0
- package/templates/marketplace/apps/fe/src/utils/number.utils.ts +12 -0
- package/templates/marketplace/apps/fe/src/utils/string.utils.ts +5 -0
- package/templates/marketplace/apps/fe/src/vite-env.d.ts +1 -0
- package/templates/marketplace/apps/fe/tsconfig.app.json +32 -0
- package/templates/marketplace/apps/fe/tsconfig.json +9 -0
- package/templates/marketplace/apps/fe/tsconfig.node.json +31 -0
- package/templates/marketplace/apps/fe/vite.config.ts +125 -0
- package/templates/marketplace/database.ts +154 -0
- package/templates/marketplace/openapi.json +2303 -0
- package/templates/marketplace/oxfmt.config.js +23 -0
- package/templates/marketplace/package.json +18 -0
- package/templates/marketplace/rulesync.jsonc +18 -0
- package/templates/marketplace/tsconfig.json +11 -0
- package/templates/space/api/_lib.ts +9 -2
- package/templates/space/package.json +1 -1
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { defineApi, eq, z } from "@robodev-ai/sdk";
|
|
2
|
+
import { cartItems, fail, findListing, isActiveForSale, toInt } from "../_lib";
|
|
3
|
+
|
|
4
|
+
const updateBody = z.object({
|
|
5
|
+
quantity: z.preprocess((value) => toInt(value, Number.NaN), z.number().int().min(1)),
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
export const put = defineApi({
|
|
9
|
+
auth: "required",
|
|
10
|
+
body: updateBody,
|
|
11
|
+
handler: async (ctx) => {
|
|
12
|
+
const [row] = await ctx.db
|
|
13
|
+
.select()
|
|
14
|
+
.from(cartItems)
|
|
15
|
+
.where(eq(cartItems.id, ctx.params.id))
|
|
16
|
+
.limit(1);
|
|
17
|
+
if (!row || row.userId !== ctx.user!.id) return fail(404, "Cart item not found");
|
|
18
|
+
const listing = await findListing(ctx.db, row.listingId);
|
|
19
|
+
if (!listing) return fail(404, "Listing not found");
|
|
20
|
+
if (!isActiveForSale(listing) || ctx.body.quantity > listing.quantity) {
|
|
21
|
+
return fail(409, "Insufficient quantity");
|
|
22
|
+
}
|
|
23
|
+
const [updated] = await ctx.db
|
|
24
|
+
.update(cartItems)
|
|
25
|
+
.set({ quantity: ctx.body.quantity })
|
|
26
|
+
.where(eq(cartItems.id, row.id))
|
|
27
|
+
.returning();
|
|
28
|
+
return {
|
|
29
|
+
id: updated!.id,
|
|
30
|
+
userId: updated!.userId,
|
|
31
|
+
listingId: updated!.listingId,
|
|
32
|
+
quantity: updated!.quantity,
|
|
33
|
+
};
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const remove = defineApi({
|
|
38
|
+
auth: "required",
|
|
39
|
+
handler: async (ctx) => {
|
|
40
|
+
const [row] = await ctx.db
|
|
41
|
+
.select()
|
|
42
|
+
.from(cartItems)
|
|
43
|
+
.where(eq(cartItems.id, ctx.params.id))
|
|
44
|
+
.limit(1);
|
|
45
|
+
if (!row || row.userId !== ctx.user!.id) return fail(404, "Cart item not found");
|
|
46
|
+
await ctx.db.delete(cartItems).where(eq(cartItems.id, row.id));
|
|
47
|
+
return { status: 204, body: null };
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
export { remove as delete };
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { and, defineApi, eq, z } from "@robodev-ai/sdk";
|
|
2
|
+
import { cartItems, enrichOne, fail, findListing, isActiveForSale, toInt } from "./_lib";
|
|
3
|
+
|
|
4
|
+
const addBody = z.object({
|
|
5
|
+
listingId: z.string().min(1),
|
|
6
|
+
quantity: z.preprocess((value) => toInt(value, 1), z.number().int().min(1)),
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
export const get = defineApi({
|
|
10
|
+
auth: "required",
|
|
11
|
+
handler: async (ctx) => {
|
|
12
|
+
const rows = await ctx.db.select().from(cartItems).where(eq(cartItems.userId, ctx.user!.id));
|
|
13
|
+
const items = [];
|
|
14
|
+
for (const row of rows) {
|
|
15
|
+
const listing = await findListing(ctx.db, row.listingId);
|
|
16
|
+
if (!listing) continue;
|
|
17
|
+
items.push({
|
|
18
|
+
id: row.id,
|
|
19
|
+
userId: row.userId,
|
|
20
|
+
listingId: row.listingId,
|
|
21
|
+
quantity: row.quantity,
|
|
22
|
+
listing: await enrichOne(ctx, listing, ctx.user!.id),
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
return items;
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
export const post = defineApi({
|
|
30
|
+
auth: "required",
|
|
31
|
+
body: addBody,
|
|
32
|
+
handler: async (ctx) => {
|
|
33
|
+
const listing = await findListing(ctx.db, ctx.body.listingId);
|
|
34
|
+
if (!listing) return fail(404, "Listing not found");
|
|
35
|
+
if (listing.sellerId === ctx.user!.id) return fail(400, "You cannot add your own listing");
|
|
36
|
+
if (!isActiveForSale(listing)) return fail(409, "Listing is not available");
|
|
37
|
+
if (ctx.body.quantity > listing.quantity) return fail(409, "Insufficient quantity");
|
|
38
|
+
|
|
39
|
+
const [current] = await ctx.db
|
|
40
|
+
.select()
|
|
41
|
+
.from(cartItems)
|
|
42
|
+
.where(and(eq(cartItems.userId, ctx.user!.id), eq(cartItems.listingId, listing.id)))
|
|
43
|
+
.limit(1);
|
|
44
|
+
const nextQty = (current?.quantity ?? 0) + ctx.body.quantity;
|
|
45
|
+
if (nextQty > listing.quantity) return fail(409, "Insufficient quantity");
|
|
46
|
+
|
|
47
|
+
const [row] = current
|
|
48
|
+
? await ctx.db
|
|
49
|
+
.update(cartItems)
|
|
50
|
+
.set({ quantity: nextQty })
|
|
51
|
+
.where(eq(cartItems.id, current.id))
|
|
52
|
+
.returning()
|
|
53
|
+
: await ctx.db
|
|
54
|
+
.insert(cartItems)
|
|
55
|
+
.values({
|
|
56
|
+
userId: ctx.user!.id,
|
|
57
|
+
listingId: listing.id,
|
|
58
|
+
quantity: ctx.body.quantity,
|
|
59
|
+
})
|
|
60
|
+
.returning();
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
id: row!.id,
|
|
64
|
+
userId: row!.userId,
|
|
65
|
+
listingId: row!.listingId,
|
|
66
|
+
quantity: row!.quantity,
|
|
67
|
+
listing: await enrichOne(ctx, listing, ctx.user!.id),
|
|
68
|
+
};
|
|
69
|
+
},
|
|
70
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { defineApi } from "@robodev-ai/sdk";
|
|
2
|
+
import { categories, ensureSeed } from "./_lib";
|
|
3
|
+
|
|
4
|
+
export const get = defineApi({
|
|
5
|
+
auth: "required",
|
|
6
|
+
handler: async (ctx) => {
|
|
7
|
+
await ensureSeed(ctx.db);
|
|
8
|
+
const rows = await ctx.db.select().from(categories);
|
|
9
|
+
return rows.map((row) => ({ id: row.id, slug: row.slug, name: row.name }));
|
|
10
|
+
},
|
|
11
|
+
});
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { defineApi, eq, z } from "@robodev-ai/sdk";
|
|
2
|
+
import {
|
|
3
|
+
cartItems,
|
|
4
|
+
fail,
|
|
5
|
+
findListing,
|
|
6
|
+
isActiveForSale,
|
|
7
|
+
isSeedRecipient,
|
|
8
|
+
listings,
|
|
9
|
+
orderItems,
|
|
10
|
+
orders,
|
|
11
|
+
ORDER_STATUS,
|
|
12
|
+
toOrderDto,
|
|
13
|
+
} from "./_lib";
|
|
14
|
+
|
|
15
|
+
const emptyToUndefined = (value: unknown) => (value === "" || value == null ? undefined : value);
|
|
16
|
+
|
|
17
|
+
const CARD_KEYS = ["pan", "number", "expiry", "cvc"] as const;
|
|
18
|
+
|
|
19
|
+
const checkoutBody = z
|
|
20
|
+
.object({
|
|
21
|
+
shippingName: z.string().min(1),
|
|
22
|
+
shippingLine1: z.string().min(1),
|
|
23
|
+
shippingLine2: z.preprocess(emptyToUndefined, z.string().optional()),
|
|
24
|
+
shippingCity: z.string().min(1),
|
|
25
|
+
shippingRegion: z.preprocess(emptyToUndefined, z.string().optional()),
|
|
26
|
+
shippingPostal: z.string().min(1),
|
|
27
|
+
shippingCountry: z.string().min(1),
|
|
28
|
+
last4: z.preprocess(
|
|
29
|
+
emptyToUndefined,
|
|
30
|
+
z
|
|
31
|
+
.string()
|
|
32
|
+
.regex(/^\d{4}$/)
|
|
33
|
+
.optional(),
|
|
34
|
+
),
|
|
35
|
+
})
|
|
36
|
+
.passthrough();
|
|
37
|
+
|
|
38
|
+
export const post = defineApi({
|
|
39
|
+
auth: "required",
|
|
40
|
+
body: checkoutBody,
|
|
41
|
+
handler: async (ctx) => {
|
|
42
|
+
const raw = ctx.body as Record<string, unknown>;
|
|
43
|
+
for (const key of CARD_KEYS) {
|
|
44
|
+
if (Object.prototype.hasOwnProperty.call(raw, key)) {
|
|
45
|
+
return fail(400, "Card number fields are not accepted");
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const cart = await ctx.db.select().from(cartItems).where(eq(cartItems.userId, ctx.user!.id));
|
|
50
|
+
if (cart.length === 0) return fail(400, "Cart is empty");
|
|
51
|
+
|
|
52
|
+
const lines: {
|
|
53
|
+
cartId: string;
|
|
54
|
+
listing: NonNullable<Awaited<ReturnType<typeof findListing>>>;
|
|
55
|
+
quantity: number;
|
|
56
|
+
}[] = [];
|
|
57
|
+
for (const item of cart) {
|
|
58
|
+
const listing = await findListing(ctx.db, item.listingId);
|
|
59
|
+
if (!listing) return fail(409, "Listing is not available");
|
|
60
|
+
if (listing.sellerId === ctx.user!.id) return fail(400, "You cannot buy your own listing");
|
|
61
|
+
if (!isActiveForSale(listing) || item.quantity > listing.quantity) {
|
|
62
|
+
return fail(409, "Insufficient quantity");
|
|
63
|
+
}
|
|
64
|
+
lines.push({ cartId: item.id, listing, quantity: item.quantity });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const bySeller = new Map<string, typeof lines>();
|
|
68
|
+
for (const line of lines) {
|
|
69
|
+
const group = bySeller.get(line.listing.sellerId) ?? [];
|
|
70
|
+
group.push(line);
|
|
71
|
+
bySeller.set(line.listing.sellerId, group);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const created = [];
|
|
75
|
+
const now = new Date();
|
|
76
|
+
for (const group of bySeller.values()) {
|
|
77
|
+
for (const line of group) {
|
|
78
|
+
const latest = await findListing(ctx.db, line.listing.id);
|
|
79
|
+
if (!latest || !isActiveForSale(latest) || line.quantity > latest.quantity) {
|
|
80
|
+
return fail(409, "Insufficient quantity");
|
|
81
|
+
}
|
|
82
|
+
await ctx.db
|
|
83
|
+
.update(listings)
|
|
84
|
+
.set({
|
|
85
|
+
quantity: latest.quantity - line.quantity,
|
|
86
|
+
updatedAt: now,
|
|
87
|
+
})
|
|
88
|
+
.where(eq(listings.id, latest.id));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const shippingCents = group[0]!.listing.shippingCents;
|
|
92
|
+
const lineTotal = group.reduce(
|
|
93
|
+
(sum, line) => sum + line.listing.priceCents * line.quantity,
|
|
94
|
+
0,
|
|
95
|
+
);
|
|
96
|
+
const [order] = await ctx.db
|
|
97
|
+
.insert(orders)
|
|
98
|
+
.values({
|
|
99
|
+
buyerId: ctx.user!.id,
|
|
100
|
+
buyerEmail: ctx.user!.email,
|
|
101
|
+
sellerId: group[0]!.listing.sellerId,
|
|
102
|
+
sellerEmail: group[0]!.listing.sellerEmail,
|
|
103
|
+
status: ORDER_STATUS.paid,
|
|
104
|
+
shippingName: ctx.body.shippingName,
|
|
105
|
+
shippingLine1: ctx.body.shippingLine1,
|
|
106
|
+
shippingLine2: ctx.body.shippingLine2 ?? null,
|
|
107
|
+
shippingCity: ctx.body.shippingCity,
|
|
108
|
+
shippingRegion: ctx.body.shippingRegion ?? null,
|
|
109
|
+
shippingPostal: ctx.body.shippingPostal,
|
|
110
|
+
shippingCountry: ctx.body.shippingCountry,
|
|
111
|
+
shippingCents,
|
|
112
|
+
totalCents: lineTotal + shippingCents,
|
|
113
|
+
last4: ctx.body.last4 ?? null,
|
|
114
|
+
createdAt: now,
|
|
115
|
+
updatedAt: now,
|
|
116
|
+
})
|
|
117
|
+
.returning();
|
|
118
|
+
|
|
119
|
+
const itemRows = [];
|
|
120
|
+
for (const line of group) {
|
|
121
|
+
const [item] = await ctx.db
|
|
122
|
+
.insert(orderItems)
|
|
123
|
+
.values({
|
|
124
|
+
orderId: order!.id,
|
|
125
|
+
listingId: line.listing.id,
|
|
126
|
+
title: line.listing.title,
|
|
127
|
+
qty: line.quantity,
|
|
128
|
+
unitPriceCents: line.listing.priceCents,
|
|
129
|
+
})
|
|
130
|
+
.returning();
|
|
131
|
+
itemRows.push(item!);
|
|
132
|
+
await ctx.db.delete(cartItems).where(eq(cartItems.id, line.cartId));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const dto = toOrderDto(order!, itemRows);
|
|
136
|
+
created.push(dto);
|
|
137
|
+
|
|
138
|
+
const itemSummary = itemRows.map((item) => `${item.qty} × ${item.title}`).join(", ");
|
|
139
|
+
if (!isSeedRecipient(order!.sellerEmail, order!.sellerId)) {
|
|
140
|
+
await ctx.email.send({
|
|
141
|
+
to: order!.sellerEmail,
|
|
142
|
+
subject: "Order placed",
|
|
143
|
+
text: `${ctx.user!.email} placed an order (${order!.id}): ${itemSummary}. Total ${order!.totalCents} cents.`,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
if (!isSeedRecipient(order!.buyerEmail)) {
|
|
147
|
+
await ctx.email.send({
|
|
148
|
+
to: order!.buyerEmail,
|
|
149
|
+
subject: "Order receipt",
|
|
150
|
+
text: `Your order ${order!.id} is paid. ${itemSummary}. Total ${order!.totalCents} cents.`,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return created;
|
|
156
|
+
},
|
|
157
|
+
});
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { defineApi, eq, z } from "@robodev-ai/sdk";
|
|
2
|
+
import {
|
|
3
|
+
enrichOne,
|
|
4
|
+
ensureSeed,
|
|
5
|
+
fail,
|
|
6
|
+
findCategory,
|
|
7
|
+
findListing,
|
|
8
|
+
listingUploadFiles,
|
|
9
|
+
listings,
|
|
10
|
+
LISTING_STATUS,
|
|
11
|
+
parseListingWrite,
|
|
12
|
+
parseRemoveImageIds,
|
|
13
|
+
replaceListingImages,
|
|
14
|
+
toInt,
|
|
15
|
+
} from "../_lib";
|
|
16
|
+
|
|
17
|
+
const emptyToUndefined = (value: unknown) => (value === "" ? undefined : value);
|
|
18
|
+
|
|
19
|
+
const listingBody = z.object({
|
|
20
|
+
title: z.string().min(1),
|
|
21
|
+
description: z.preprocess(emptyToUndefined, z.string().optional()),
|
|
22
|
+
categoryId: z.string().min(1),
|
|
23
|
+
priceCents: z.preprocess((value) => toInt(value, Number.NaN), z.number().int().min(0)),
|
|
24
|
+
quantity: z.preprocess((value) => toInt(value, Number.NaN), z.number().int().min(0)),
|
|
25
|
+
shippingCents: z.preprocess((value) => toInt(value, Number.NaN), z.number().int().min(0)),
|
|
26
|
+
shippingDays: z.preprocess((value) => toInt(value, Number.NaN), z.number().int().min(1)),
|
|
27
|
+
status: z.preprocess(emptyToUndefined, z.string().optional()),
|
|
28
|
+
removeImageIds: z.preprocess(emptyToUndefined, z.string().optional()),
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
export const get = defineApi({
|
|
32
|
+
auth: "required",
|
|
33
|
+
handler: async (ctx) => {
|
|
34
|
+
await ensureSeed(ctx.db);
|
|
35
|
+
const listing = await findListing(ctx.db, ctx.params.id);
|
|
36
|
+
if (!listing) return fail(404, "Listing not found");
|
|
37
|
+
return enrichOne(ctx, listing, ctx.user!.id);
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
export const put = defineApi({
|
|
42
|
+
auth: "required",
|
|
43
|
+
multipart: true,
|
|
44
|
+
body: listingBody,
|
|
45
|
+
handler: async (ctx) => {
|
|
46
|
+
const listing = await findListing(ctx.db, ctx.params.id);
|
|
47
|
+
if (!listing) return fail(404, "Listing not found");
|
|
48
|
+
if (listing.sellerId !== ctx.user!.id)
|
|
49
|
+
return fail(403, "You can only update your own listings");
|
|
50
|
+
if (!(await findCategory(ctx.db, ctx.body.categoryId))) {
|
|
51
|
+
return fail(404, "Category not found");
|
|
52
|
+
}
|
|
53
|
+
const parsed = parseListingWrite({
|
|
54
|
+
title: ctx.body.title,
|
|
55
|
+
description: ctx.body.description,
|
|
56
|
+
categoryId: ctx.body.categoryId,
|
|
57
|
+
priceCents: ctx.body.priceCents,
|
|
58
|
+
quantity: ctx.body.quantity,
|
|
59
|
+
shippingCents: ctx.body.shippingCents,
|
|
60
|
+
shippingDays: ctx.body.shippingDays,
|
|
61
|
+
status: ctx.body.status,
|
|
62
|
+
});
|
|
63
|
+
if ("status" in parsed && "body" in parsed) return parsed;
|
|
64
|
+
|
|
65
|
+
const files = listingUploadFiles(ctx.files);
|
|
66
|
+
const removeImageIds = parseRemoveImageIds(ctx.body.removeImageIds);
|
|
67
|
+
const saved = await replaceListingImages(ctx, listing.id, files, removeImageIds, 0);
|
|
68
|
+
if ("status" in saved) return saved;
|
|
69
|
+
|
|
70
|
+
const [updated] = await ctx.db
|
|
71
|
+
.update(listings)
|
|
72
|
+
.set({
|
|
73
|
+
...parsed,
|
|
74
|
+
updatedAt: new Date(),
|
|
75
|
+
})
|
|
76
|
+
.where(eq(listings.id, listing.id))
|
|
77
|
+
.returning();
|
|
78
|
+
if (!updated) return fail(404, "Listing not found");
|
|
79
|
+
return enrichOne(ctx, updated, ctx.user!.id);
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const deactivate = defineApi({
|
|
84
|
+
auth: "required",
|
|
85
|
+
handler: async (ctx) => {
|
|
86
|
+
const listing = await findListing(ctx.db, ctx.params.id);
|
|
87
|
+
if (!listing) return fail(404, "Listing not found");
|
|
88
|
+
if (listing.sellerId !== ctx.user!.id) {
|
|
89
|
+
return fail(403, "You can only deactivate your own listings");
|
|
90
|
+
}
|
|
91
|
+
const [updated] = await ctx.db
|
|
92
|
+
.update(listings)
|
|
93
|
+
.set({ status: LISTING_STATUS.inactive, updatedAt: new Date() })
|
|
94
|
+
.where(eq(listings.id, listing.id))
|
|
95
|
+
.returning();
|
|
96
|
+
if (!updated) return fail(404, "Listing not found");
|
|
97
|
+
return enrichOne(ctx, updated, ctx.user!.id);
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
export { deactivate as delete };
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { defineApi, desc } from "@robodev-ai/sdk";
|
|
2
|
+
import {
|
|
3
|
+
browsePredicate,
|
|
4
|
+
enrichListings,
|
|
5
|
+
ensureSeed,
|
|
6
|
+
listings,
|
|
7
|
+
paginate,
|
|
8
|
+
parseOptionalInt,
|
|
9
|
+
} from "../_lib";
|
|
10
|
+
|
|
11
|
+
export const get = defineApi({
|
|
12
|
+
auth: "required",
|
|
13
|
+
handler: async (ctx) => {
|
|
14
|
+
await ensureSeed(ctx.db);
|
|
15
|
+
const query = ctx.query as Record<string, unknown>;
|
|
16
|
+
const search = typeof query.search === "string" ? query.search : undefined;
|
|
17
|
+
const categoryId = typeof query.categoryId === "string" ? query.categoryId : undefined;
|
|
18
|
+
const rows = await ctx.db
|
|
19
|
+
.select()
|
|
20
|
+
.from(listings)
|
|
21
|
+
.where(browsePredicate(search, categoryId))
|
|
22
|
+
.orderBy(desc(listings.createdAt));
|
|
23
|
+
const page = paginate(rows, parseOptionalInt(query.page), parseOptionalInt(query.limit));
|
|
24
|
+
return {
|
|
25
|
+
...page,
|
|
26
|
+
items: await enrichListings(ctx, page.items, ctx.user!.id),
|
|
27
|
+
};
|
|
28
|
+
},
|
|
29
|
+
});
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { defineApi, desc, eq, z } from "@robodev-ai/sdk";
|
|
2
|
+
import {
|
|
3
|
+
browsePredicate,
|
|
4
|
+
enrichListings,
|
|
5
|
+
enrichOne,
|
|
6
|
+
ensureSeed,
|
|
7
|
+
fail,
|
|
8
|
+
findCategory,
|
|
9
|
+
isTruthyQuery,
|
|
10
|
+
listingUploadFiles,
|
|
11
|
+
listings,
|
|
12
|
+
MAX_LISTING_IMAGES,
|
|
13
|
+
parseListingWrite,
|
|
14
|
+
replaceListingImages,
|
|
15
|
+
toInt,
|
|
16
|
+
} from "./_lib";
|
|
17
|
+
|
|
18
|
+
const emptyToUndefined = (value: unknown) => (value === "" ? undefined : value);
|
|
19
|
+
|
|
20
|
+
const listingBody = z.object({
|
|
21
|
+
title: z.string().min(1),
|
|
22
|
+
description: z.preprocess(emptyToUndefined, z.string().optional()),
|
|
23
|
+
categoryId: z.string().min(1),
|
|
24
|
+
priceCents: z.preprocess((value) => toInt(value, Number.NaN), z.number().int().min(0)),
|
|
25
|
+
quantity: z.preprocess((value) => toInt(value, Number.NaN), z.number().int().min(0)),
|
|
26
|
+
shippingCents: z.preprocess((value) => toInt(value, Number.NaN), z.number().int().min(0)),
|
|
27
|
+
shippingDays: z.preprocess((value) => toInt(value, Number.NaN), z.number().int().min(1)),
|
|
28
|
+
status: z.preprocess(emptyToUndefined, z.string().optional()),
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
export const get = defineApi({
|
|
32
|
+
auth: "required",
|
|
33
|
+
handler: async (ctx) => {
|
|
34
|
+
await ensureSeed(ctx.db);
|
|
35
|
+
const query = ctx.query as Record<string, unknown>;
|
|
36
|
+
const mine = isTruthyQuery(query.mine);
|
|
37
|
+
const search = typeof query.search === "string" ? query.search : undefined;
|
|
38
|
+
const categoryId = typeof query.categoryId === "string" ? query.categoryId : undefined;
|
|
39
|
+
const rows = mine
|
|
40
|
+
? await ctx.db
|
|
41
|
+
.select()
|
|
42
|
+
.from(listings)
|
|
43
|
+
.where(eq(listings.sellerId, ctx.user!.id))
|
|
44
|
+
.orderBy(desc(listings.createdAt))
|
|
45
|
+
: await ctx.db
|
|
46
|
+
.select()
|
|
47
|
+
.from(listings)
|
|
48
|
+
.where(browsePredicate(search, categoryId))
|
|
49
|
+
.orderBy(desc(listings.createdAt));
|
|
50
|
+
return enrichListings(ctx, rows, ctx.user!.id);
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
export const post = defineApi({
|
|
55
|
+
auth: "required",
|
|
56
|
+
multipart: true,
|
|
57
|
+
body: listingBody,
|
|
58
|
+
handler: async (ctx) => {
|
|
59
|
+
if (!(await findCategory(ctx.db, ctx.body.categoryId))) {
|
|
60
|
+
return fail(404, "Category not found");
|
|
61
|
+
}
|
|
62
|
+
const parsed = parseListingWrite({
|
|
63
|
+
title: ctx.body.title,
|
|
64
|
+
description: ctx.body.description,
|
|
65
|
+
categoryId: ctx.body.categoryId,
|
|
66
|
+
priceCents: ctx.body.priceCents,
|
|
67
|
+
quantity: ctx.body.quantity,
|
|
68
|
+
shippingCents: ctx.body.shippingCents,
|
|
69
|
+
shippingDays: ctx.body.shippingDays,
|
|
70
|
+
status: ctx.body.status,
|
|
71
|
+
});
|
|
72
|
+
if ("status" in parsed && "body" in parsed) return parsed;
|
|
73
|
+
const uploaded = listingUploadFiles(ctx.files);
|
|
74
|
+
if (uploaded.length > MAX_LISTING_IMAGES) return fail(400, "Too many images");
|
|
75
|
+
const files = uploaded.slice(0, MAX_LISTING_IMAGES);
|
|
76
|
+
|
|
77
|
+
const [row] = await ctx.db
|
|
78
|
+
.insert(listings)
|
|
79
|
+
.values({
|
|
80
|
+
sellerId: ctx.user!.id,
|
|
81
|
+
sellerName: ctx.user!.name ?? ctx.user!.email,
|
|
82
|
+
sellerEmail: ctx.user!.email,
|
|
83
|
+
...parsed,
|
|
84
|
+
})
|
|
85
|
+
.returning();
|
|
86
|
+
const saved = await replaceListingImages(ctx, row!.id, files, [], 0);
|
|
87
|
+
if ("status" in saved) return saved;
|
|
88
|
+
return enrichOne(ctx, row!, ctx.user!.id);
|
|
89
|
+
},
|
|
90
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** GET /api/me — current project user (Robodev Auth required). */
|
|
2
|
+
import { defineApi, z } from "@robodev-ai/sdk";
|
|
3
|
+
|
|
4
|
+
export const get = defineApi({
|
|
5
|
+
auth: "required",
|
|
6
|
+
response: z.object({
|
|
7
|
+
user: z.object({
|
|
8
|
+
id: z.string(),
|
|
9
|
+
email: z.string(),
|
|
10
|
+
name: z.string().nullable().optional(),
|
|
11
|
+
}),
|
|
12
|
+
}),
|
|
13
|
+
handler: async ({ user }) => ({ user }),
|
|
14
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { defineApi, eq } from "@robodev-ai/sdk";
|
|
2
|
+
import { fail, orderItems, orders, ORDER_STATUS, sendMail, toOrderDto } from "../../_lib";
|
|
3
|
+
|
|
4
|
+
export const post = defineApi({
|
|
5
|
+
auth: "required",
|
|
6
|
+
handler: async (ctx) => {
|
|
7
|
+
const [order] = await ctx.db.select().from(orders).where(eq(orders.id, ctx.params.id)).limit(1);
|
|
8
|
+
if (!order) return fail(404, "Order not found");
|
|
9
|
+
if (order.buyerId !== ctx.user!.id) {
|
|
10
|
+
return fail(403, "Only the buyer can complete this order");
|
|
11
|
+
}
|
|
12
|
+
if (order.status !== ORDER_STATUS.shipped) return fail(409, "Order is not shipped");
|
|
13
|
+
const [updated] = await ctx.db
|
|
14
|
+
.update(orders)
|
|
15
|
+
.set({ status: ORDER_STATUS.completed, updatedAt: new Date() })
|
|
16
|
+
.where(eq(orders.id, order.id))
|
|
17
|
+
.returning();
|
|
18
|
+
const items = await ctx.db.select().from(orderItems).where(eq(orderItems.orderId, order.id));
|
|
19
|
+
await sendMail(ctx.email, {
|
|
20
|
+
to: order.sellerEmail,
|
|
21
|
+
sellerId: order.sellerId,
|
|
22
|
+
subject: "Order completed",
|
|
23
|
+
text: `Order ${order.id} was marked completed by the buyer.`,
|
|
24
|
+
});
|
|
25
|
+
return toOrderDto(updated!, items);
|
|
26
|
+
},
|
|
27
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { defineApi, eq } from "@robodev-ai/sdk";
|
|
2
|
+
import { fail, orderItems, orders, ORDER_STATUS, sendMail, toOrderDto } from "../../_lib";
|
|
3
|
+
|
|
4
|
+
export const post = defineApi({
|
|
5
|
+
auth: "required",
|
|
6
|
+
handler: async (ctx) => {
|
|
7
|
+
const [order] = await ctx.db.select().from(orders).where(eq(orders.id, ctx.params.id)).limit(1);
|
|
8
|
+
if (!order) return fail(404, "Order not found");
|
|
9
|
+
if (order.sellerId !== ctx.user!.id) return fail(403, "Only the seller can ship this order");
|
|
10
|
+
if (order.status !== ORDER_STATUS.paid) return fail(409, "Order is not awaiting shipment");
|
|
11
|
+
const [updated] = await ctx.db
|
|
12
|
+
.update(orders)
|
|
13
|
+
.set({ status: ORDER_STATUS.shipped, updatedAt: new Date() })
|
|
14
|
+
.where(eq(orders.id, order.id))
|
|
15
|
+
.returning();
|
|
16
|
+
const items = await ctx.db.select().from(orderItems).where(eq(orderItems.orderId, order.id));
|
|
17
|
+
await sendMail(ctx.email, {
|
|
18
|
+
to: order.buyerEmail,
|
|
19
|
+
subject: "Order shipped",
|
|
20
|
+
text: `Your order ${order.id} has shipped.`,
|
|
21
|
+
});
|
|
22
|
+
return toOrderDto(updated!, items);
|
|
23
|
+
},
|
|
24
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { defineApi, eq } from "@robodev-ai/sdk";
|
|
2
|
+
import { fail, orderItems, orders, toOrderDto } from "../_lib";
|
|
3
|
+
|
|
4
|
+
export const get = defineApi({
|
|
5
|
+
auth: "required",
|
|
6
|
+
handler: async (ctx) => {
|
|
7
|
+
const [order] = await ctx.db.select().from(orders).where(eq(orders.id, ctx.params.id)).limit(1);
|
|
8
|
+
if (!order) return fail(404, "Order not found");
|
|
9
|
+
if (order.buyerId !== ctx.user!.id && order.sellerId !== ctx.user!.id) {
|
|
10
|
+
return fail(403, "You cannot view this order");
|
|
11
|
+
}
|
|
12
|
+
const items = await ctx.db.select().from(orderItems).where(eq(orderItems.orderId, order.id));
|
|
13
|
+
return toOrderDto(order, items);
|
|
14
|
+
},
|
|
15
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { defineApi, desc, eq } from "@robodev-ai/sdk";
|
|
2
|
+
import { orderItems, orders, toOrderDto } from "./_lib";
|
|
3
|
+
|
|
4
|
+
export const get = defineApi({
|
|
5
|
+
auth: "required",
|
|
6
|
+
handler: async (ctx) => {
|
|
7
|
+
const rows = await ctx.db
|
|
8
|
+
.select()
|
|
9
|
+
.from(orders)
|
|
10
|
+
.where(eq(orders.buyerId, ctx.user!.id))
|
|
11
|
+
.orderBy(desc(orders.createdAt));
|
|
12
|
+
const result = [];
|
|
13
|
+
for (const order of rows) {
|
|
14
|
+
const items = await ctx.db.select().from(orderItems).where(eq(orderItems.orderId, order.id));
|
|
15
|
+
result.push(toOrderDto(order, items));
|
|
16
|
+
}
|
|
17
|
+
return result;
|
|
18
|
+
},
|
|
19
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { and, defineApi, eq, z } from "@robodev-ai/sdk";
|
|
2
|
+
import { fail, orderItems, orders, ORDER_STATUS, reviews, toReviewDto } from "./_lib";
|
|
3
|
+
|
|
4
|
+
const emptyToUndefined = (value: unknown) => (value === "" ? undefined : value);
|
|
5
|
+
|
|
6
|
+
export const get = defineApi({
|
|
7
|
+
auth: "required",
|
|
8
|
+
handler: async (ctx) => {
|
|
9
|
+
const query = ctx.query as Record<string, unknown>;
|
|
10
|
+
const listingId = typeof query.listingId === "string" ? query.listingId : "";
|
|
11
|
+
if (!listingId) return fail(400, "listingId is required");
|
|
12
|
+
const rows = await ctx.db.select().from(reviews).where(eq(reviews.listingId, listingId));
|
|
13
|
+
return rows.map(toReviewDto);
|
|
14
|
+
},
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
export const post = defineApi({
|
|
18
|
+
auth: "required",
|
|
19
|
+
body: z.object({
|
|
20
|
+
listingId: z.string().min(1),
|
|
21
|
+
orderId: z.string().min(1),
|
|
22
|
+
rating: z.number().int().min(1).max(5),
|
|
23
|
+
body: z.preprocess(emptyToUndefined, z.string().optional()),
|
|
24
|
+
}),
|
|
25
|
+
handler: async (ctx) => {
|
|
26
|
+
const [order] = await ctx.db
|
|
27
|
+
.select()
|
|
28
|
+
.from(orders)
|
|
29
|
+
.where(eq(orders.id, ctx.body.orderId))
|
|
30
|
+
.limit(1);
|
|
31
|
+
if (!order) return fail(404, "Order not found");
|
|
32
|
+
if (order.buyerId !== ctx.user!.id) return fail(403, "Only the buyer can review");
|
|
33
|
+
if (order.status !== ORDER_STATUS.completed) return fail(403, "Order is not completed");
|
|
34
|
+
const [line] = await ctx.db
|
|
35
|
+
.select()
|
|
36
|
+
.from(orderItems)
|
|
37
|
+
.where(and(eq(orderItems.orderId, order.id), eq(orderItems.listingId, ctx.body.listingId)))
|
|
38
|
+
.limit(1);
|
|
39
|
+
if (!line) return fail(403, "Listing is not on this order");
|
|
40
|
+
const [existing] = await ctx.db
|
|
41
|
+
.select()
|
|
42
|
+
.from(reviews)
|
|
43
|
+
.where(and(eq(reviews.orderId, order.id), eq(reviews.listingId, ctx.body.listingId)))
|
|
44
|
+
.limit(1);
|
|
45
|
+
if (existing) return fail(409, "Review already exists");
|
|
46
|
+
const [row] = await ctx.db
|
|
47
|
+
.insert(reviews)
|
|
48
|
+
.values({
|
|
49
|
+
listingId: ctx.body.listingId,
|
|
50
|
+
orderId: order.id,
|
|
51
|
+
buyerId: ctx.user!.id,
|
|
52
|
+
rating: ctx.body.rating,
|
|
53
|
+
body: ctx.body.body ?? null,
|
|
54
|
+
})
|
|
55
|
+
.returning();
|
|
56
|
+
return toReviewDto(row!);
|
|
57
|
+
},
|
|
58
|
+
});
|