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.
Files changed (178) hide show
  1. package/package.json +1 -1
  2. package/src/emit-starter.test.ts +36 -0
  3. package/templates/auth-chat/package.json +1 -1
  4. package/templates/backend/api/_lib.ts +9 -2
  5. package/templates/backend/package.json +1 -1
  6. package/templates/catalog.json +5 -0
  7. package/templates/empty/package.json +1 -1
  8. package/templates/marketplace/.config/local.spa.template.yml +27 -0
  9. package/templates/marketplace/.oxlint-base.json +29 -0
  10. package/templates/marketplace/.oxlintrc.json +78 -0
  11. package/templates/marketplace/.rulesync/rules/frontend-api-boundary.md +36 -0
  12. package/templates/marketplace/.rulesync/rules/frontend-component-structure.md +38 -0
  13. package/templates/marketplace/.rulesync/rules/frontend-forms.md +32 -0
  14. package/templates/marketplace/.rulesync/rules/frontend-notifications.md +24 -0
  15. package/templates/marketplace/.rulesync/rules/frontend-povio-components.md +42 -0
  16. package/templates/marketplace/.rulesync/rules/frontend-povio-ui.md +45 -0
  17. package/templates/marketplace/.rulesync/rules/frontend-query-autocomplete.md +29 -0
  18. package/templates/marketplace/.rulesync/rules/frontend-tables-and-lists.md +34 -0
  19. package/templates/marketplace/.rulesync/rules/frontend-translations.md +26 -0
  20. package/templates/marketplace/.rulesync/rules/project-overview.md +32 -0
  21. package/templates/marketplace/.rulesync/rules/robodev-api.md +30 -0
  22. package/templates/marketplace/.rulesync/rules/robodev-auth.md +40 -0
  23. package/templates/marketplace/.rulesync/rules/robodev-database.md +22 -0
  24. package/templates/marketplace/.rulesync/rules/role-based-app-structure.md +36 -0
  25. package/templates/marketplace/.rulesync/skills/media-feature/SKILL.md +21 -0
  26. package/templates/marketplace/.rulesync/skills/povio-ui-styling/SKILL.md +208 -0
  27. package/templates/marketplace/.rulesync/skills/povio-ui-styling/agents/openai.yaml +4 -0
  28. package/templates/marketplace/.rulesync/skills/robodev-api-route/SKILL.md +13 -0
  29. package/templates/marketplace/.rulesync/skills/robodev-table/SKILL.md +12 -0
  30. package/templates/marketplace/README.md +40 -0
  31. package/templates/marketplace/api/_lib.ts +677 -0
  32. package/templates/marketplace/api/cart/[id].ts +51 -0
  33. package/templates/marketplace/api/cart.ts +70 -0
  34. package/templates/marketplace/api/categories.ts +11 -0
  35. package/templates/marketplace/api/checkout.ts +157 -0
  36. package/templates/marketplace/api/health.ts +7 -0
  37. package/templates/marketplace/api/listings/[id].ts +101 -0
  38. package/templates/marketplace/api/listings/paginate.ts +29 -0
  39. package/templates/marketplace/api/listings.ts +90 -0
  40. package/templates/marketplace/api/me.ts +14 -0
  41. package/templates/marketplace/api/orders/[id]/complete.ts +27 -0
  42. package/templates/marketplace/api/orders/[id]/ship.ts +24 -0
  43. package/templates/marketplace/api/orders/[id].ts +15 -0
  44. package/templates/marketplace/api/purchases.ts +19 -0
  45. package/templates/marketplace/api/reviews.ts +58 -0
  46. package/templates/marketplace/api/sales.ts +19 -0
  47. package/templates/marketplace/api/watches/[listingId].ts +18 -0
  48. package/templates/marketplace/api/watches.ts +51 -0
  49. package/templates/marketplace/apps/fe/index.html +24 -0
  50. package/templates/marketplace/apps/fe/openapi-codegen.config.ts +72 -0
  51. package/templates/marketplace/apps/fe/package.json +84 -0
  52. package/templates/marketplace/apps/fe/public/apple-touch-icon.png +0 -0
  53. package/templates/marketplace/apps/fe/public/favicon-96x96.png +0 -0
  54. package/templates/marketplace/apps/fe/public/favicon.ico +0 -0
  55. package/templates/marketplace/apps/fe/public/favicon.svg +3 -0
  56. package/templates/marketplace/apps/fe/public/site.webmanifest +21 -0
  57. package/templates/marketplace/apps/fe/public/web-app-manifest-192x192.png +0 -0
  58. package/templates/marketplace/apps/fe/public/web-app-manifest-512x512.png +0 -0
  59. package/templates/marketplace/apps/fe/src/assets/fonts/GeneralSans-Bold.otf +0 -0
  60. package/templates/marketplace/apps/fe/src/assets/fonts/GeneralSans-Medium.otf +0 -0
  61. package/templates/marketplace/apps/fe/src/assets/fonts/GeneralSans-Regular.otf +0 -0
  62. package/templates/marketplace/apps/fe/src/assets/fonts/GeneralSans-Semibold.otf +0 -0
  63. package/templates/marketplace/apps/fe/src/assets/locales/en/translation.json +335 -0
  64. package/templates/marketplace/apps/fe/src/assets/locales/sl/translation.json +335 -0
  65. package/templates/marketplace/apps/fe/src/clients/app-rest-client.ts +15 -0
  66. package/templates/marketplace/apps/fe/src/clients/auth-token-store.ts +101 -0
  67. package/templates/marketplace/apps/fe/src/clients/rest/app-error-handler.ts +31 -0
  68. package/templates/marketplace/apps/fe/src/clients/rest/interceptors/authorization-header.interceptor.ts +15 -0
  69. package/templates/marketplace/apps/fe/src/clients/rest/interceptors/refresh-token.interceptor.ts +37 -0
  70. package/templates/marketplace/apps/fe/src/clients/rest/interceptors/response.interceptor.ts +17 -0
  71. package/templates/marketplace/apps/fe/src/components/404.tsx +18 -0
  72. package/templates/marketplace/apps/fe/src/components/features/auth/AuthBrandPanel.tsx +60 -0
  73. package/templates/marketplace/apps/fe/src/components/features/auth/AuthLayout.tsx +23 -0
  74. package/templates/marketplace/apps/fe/src/components/features/auth/LoginPage.tsx +111 -0
  75. package/templates/marketplace/apps/fe/src/components/features/auth/RegisterPage.tsx +143 -0
  76. package/templates/marketplace/apps/fe/src/components/features/marketplace/BrowsePage.tsx +76 -0
  77. package/templates/marketplace/apps/fe/src/components/features/marketplace/CartPage.tsx +88 -0
  78. package/templates/marketplace/apps/fe/src/components/features/marketplace/CheckoutPage.tsx +193 -0
  79. package/templates/marketplace/apps/fe/src/components/features/marketplace/ListingCard.tsx +50 -0
  80. package/templates/marketplace/apps/fe/src/components/features/marketplace/ListingDetailsPage.tsx +152 -0
  81. package/templates/marketplace/apps/fe/src/components/features/marketplace/ListingFormPage.tsx +243 -0
  82. package/templates/marketplace/apps/fe/src/components/features/marketplace/MyListingsPage.tsx +84 -0
  83. package/templates/marketplace/apps/fe/src/components/features/marketplace/OrdersPage.tsx +224 -0
  84. package/templates/marketplace/apps/fe/src/components/features/marketplace/WatchlistPage.tsx +56 -0
  85. package/templates/marketplace/apps/fe/src/components/features/profile/ProfilePage.tsx +96 -0
  86. package/templates/marketplace/apps/fe/src/components/googleAnalytics/GoogleAnalytics.tsx +35 -0
  87. package/templates/marketplace/apps/fe/src/components/layout/AppLayout.tsx +19 -0
  88. package/templates/marketplace/apps/fe/src/components/layout/app-header/AppHeader.tsx +95 -0
  89. package/templates/marketplace/apps/fe/src/components/layout/app-header/MobileNavigation.tsx +55 -0
  90. package/templates/marketplace/apps/fe/src/components/layout/app-header/NavLink.tsx +29 -0
  91. package/templates/marketplace/apps/fe/src/components/shared/branding/BrandLogo.tsx +13 -0
  92. package/templates/marketplace/apps/fe/src/components/shared/error/ErrorFallback.tsx +43 -0
  93. package/templates/marketplace/apps/fe/src/components/shared/error/ErrorText.tsx +20 -0
  94. package/templates/marketplace/apps/fe/src/components/shared/error/NotFound.tsx +36 -0
  95. package/templates/marketplace/apps/fe/src/components/shared/forms/RequiredLabel.tsx +21 -0
  96. package/templates/marketplace/apps/fe/src/components/shared/forms/RowInputWrapper.tsx +50 -0
  97. package/templates/marketplace/apps/fe/src/components/shared/head/AppHead.tsx +32 -0
  98. package/templates/marketplace/apps/fe/src/components/shared/head/DefaultAppHead.tsx +34 -0
  99. package/templates/marketplace/apps/fe/src/components/shared/layout/LoadingState.tsx +9 -0
  100. package/templates/marketplace/apps/fe/src/components/shared/layout/ThinPageWrapper.tsx +5 -0
  101. package/templates/marketplace/apps/fe/src/components/shared/page/PageHeader.tsx +69 -0
  102. package/templates/marketplace/apps/fe/src/components/shared/ui/Card.tsx +60 -0
  103. package/templates/marketplace/apps/fe/src/components/shared/ui/GoogleLoginButton.tsx +19 -0
  104. package/templates/marketplace/apps/fe/src/components/shared/ui/RequiredLabel.tsx +22 -0
  105. package/templates/marketplace/apps/fe/src/components/shared/ui/TableActions.tsx +22 -0
  106. package/templates/marketplace/apps/fe/src/config/app.config.ts +35 -0
  107. package/templates/marketplace/apps/fe/src/config/i18n.ts +43 -0
  108. package/templates/marketplace/apps/fe/src/config/inits/a11y.ts +14 -0
  109. package/templates/marketplace/apps/fe/src/config/inits/logger.ts +7 -0
  110. package/templates/marketplace/apps/fe/src/config/inits/sentry.ts +21 -0
  111. package/templates/marketplace/apps/fe/src/config/jwt.config.ts +2 -0
  112. package/templates/marketplace/apps/fe/src/config/query.config.ts +20 -0
  113. package/templates/marketplace/apps/fe/src/hooks/useAuth.ts +5 -0
  114. package/templates/marketplace/apps/fe/src/main.tsx +52 -0
  115. package/templates/marketplace/apps/fe/src/pages/(guest)/login.tsx +23 -0
  116. package/templates/marketplace/apps/fe/src/pages/(guest)/register.tsx +23 -0
  117. package/templates/marketplace/apps/fe/src/pages/(guest)/route.tsx +18 -0
  118. package/templates/marketplace/apps/fe/src/pages/(private)/cart.tsx +22 -0
  119. package/templates/marketplace/apps/fe/src/pages/(private)/checkout.tsx +22 -0
  120. package/templates/marketplace/apps/fe/src/pages/(private)/index.tsx +22 -0
  121. package/templates/marketplace/apps/fe/src/pages/(private)/listings/$id/index.tsx +37 -0
  122. package/templates/marketplace/apps/fe/src/pages/(private)/my-listings.tsx +22 -0
  123. package/templates/marketplace/apps/fe/src/pages/(private)/orders/$id.tsx +40 -0
  124. package/templates/marketplace/apps/fe/src/pages/(private)/orders/index.tsx +22 -0
  125. package/templates/marketplace/apps/fe/src/pages/(private)/profile.tsx +23 -0
  126. package/templates/marketplace/apps/fe/src/pages/(private)/route.tsx +18 -0
  127. package/templates/marketplace/apps/fe/src/pages/(private)/sales.tsx +22 -0
  128. package/templates/marketplace/apps/fe/src/pages/(private)/sell/$id.tsx +44 -0
  129. package/templates/marketplace/apps/fe/src/pages/(private)/sell/index.tsx +19 -0
  130. package/templates/marketplace/apps/fe/src/pages/(private)/watchlist.tsx +22 -0
  131. package/templates/marketplace/apps/fe/src/pages/(public)/auth.tsx +37 -0
  132. package/templates/marketplace/apps/fe/src/pages/(public)/route.tsx +9 -0
  133. package/templates/marketplace/apps/fe/src/pages/__root.tsx +164 -0
  134. package/templates/marketplace/apps/fe/src/providers/AppErrorBoundary.tsx +10 -0
  135. package/templates/marketplace/apps/fe/src/providers/OpenApiRuntimeProvider.tsx +31 -0
  136. package/templates/marketplace/apps/fe/src/providers/index.tsx +44 -0
  137. package/templates/marketplace/apps/fe/src/providers/jwt.provider.tsx +95 -0
  138. package/templates/marketplace/apps/fe/src/routeTree.gen.ts +435 -0
  139. package/templates/marketplace/apps/fe/src/styles/base.css +103 -0
  140. package/templates/marketplace/apps/fe/src/styles/fonts/fonts.tsx +10 -0
  141. package/templates/marketplace/apps/fe/src/styles/fonts/general-sans.css +31 -0
  142. package/templates/marketplace/apps/fe/src/styles/globals.css +28 -0
  143. package/templates/marketplace/apps/fe/src/styles/overrides/defaults/button.override.ts +536 -0
  144. package/templates/marketplace/apps/fe/src/styles/overrides/defaults/checkbox.override.ts +71 -0
  145. package/templates/marketplace/apps/fe/src/styles/overrides/defaults/input.override.ts +252 -0
  146. package/templates/marketplace/apps/fe/src/styles/overrides/defaults/label.override.ts +91 -0
  147. package/templates/marketplace/apps/fe/src/styles/overrides/defaults/modal.override.ts +57 -0
  148. package/templates/marketplace/apps/fe/src/styles/overrides/defaults/radio.override.ts +46 -0
  149. package/templates/marketplace/apps/fe/src/styles/overrides/defaults/table.override.ts +104 -0
  150. package/templates/marketplace/apps/fe/src/styles/overrides/defaults/tag.override.ts +66 -0
  151. package/templates/marketplace/apps/fe/src/styles/overrides/defaults/typography.override.ts +115 -0
  152. package/templates/marketplace/apps/fe/src/styles/overrides/outline.clsx.ts +10 -0
  153. package/templates/marketplace/apps/fe/src/styles/overrides/uiOverrides.override.ts +60 -0
  154. package/templates/marketplace/apps/fe/src/styles/theme.css +2177 -0
  155. package/templates/marketplace/apps/fe/src/types/i18next.d.ts +12 -0
  156. package/templates/marketplace/apps/fe/src/types/table.d.ts +17 -0
  157. package/templates/marketplace/apps/fe/src/types/ui.d.ts +26 -0
  158. package/templates/marketplace/apps/fe/src/types/vite-env.d.ts +20 -0
  159. package/templates/marketplace/apps/fe/src/utils/date.utils.ts +17 -0
  160. package/templates/marketplace/apps/fe/src/utils/image-fallback.ts +9 -0
  161. package/templates/marketplace/apps/fe/src/utils/listing-form.test.ts +69 -0
  162. package/templates/marketplace/apps/fe/src/utils/listing-form.ts +77 -0
  163. package/templates/marketplace/apps/fe/src/utils/listing-submit.ts +29 -0
  164. package/templates/marketplace/apps/fe/src/utils/number.utils.ts +12 -0
  165. package/templates/marketplace/apps/fe/src/utils/string.utils.ts +5 -0
  166. package/templates/marketplace/apps/fe/src/vite-env.d.ts +1 -0
  167. package/templates/marketplace/apps/fe/tsconfig.app.json +32 -0
  168. package/templates/marketplace/apps/fe/tsconfig.json +9 -0
  169. package/templates/marketplace/apps/fe/tsconfig.node.json +31 -0
  170. package/templates/marketplace/apps/fe/vite.config.ts +125 -0
  171. package/templates/marketplace/database.ts +154 -0
  172. package/templates/marketplace/openapi.json +2303 -0
  173. package/templates/marketplace/oxfmt.config.js +23 -0
  174. package/templates/marketplace/package.json +18 -0
  175. package/templates/marketplace/rulesync.jsonc +18 -0
  176. package/templates/marketplace/tsconfig.json +11 -0
  177. package/templates/space/api/_lib.ts +9 -2
  178. package/templates/space/package.json +1 -1
@@ -0,0 +1,677 @@
1
+ import { and, asc, eq, gt, ilike, inArray, or, type InferSelectModel } from "@robodev-ai/sdk";
2
+ import type { ApiHandlerContext, EmailClient, UploadedFile } from "@robodev-ai/sdk";
3
+ import {
4
+ cartItems,
5
+ categories,
6
+ listingImages,
7
+ listings,
8
+ media,
9
+ orderItems,
10
+ orders,
11
+ reviews,
12
+ watches,
13
+ } from "../database";
14
+
15
+ export const LISTING_IMAGE_RESOURCE = "listing-image";
16
+ export const SEED_SELLER_ID = "seed-seller";
17
+ export const SEED_SELLER_NAME = "Marketplace Demo";
18
+ export const SEED_SELLER_EMAIL = "seed-seller@marketplace.local";
19
+ export const MAX_LISTING_IMAGES = 8;
20
+ export const LISTING_STATUS = { active: "active", inactive: "inactive" } as const;
21
+ export const ORDER_STATUS = {
22
+ paid: "paid",
23
+ shipped: "shipped",
24
+ completed: "completed",
25
+ } as const;
26
+
27
+ const ALLOWED_LISTING_IMAGE_TYPES = new Set(["image/jpeg", "image/png", "image/webp"]);
28
+ const MAX_LISTING_IMAGE_BYTES = 2 * 1024 * 1024;
29
+
30
+ export const CATEGORY_SEEDS = [
31
+ { slug: "electronics", name: "Electronics" },
32
+ { slug: "home", name: "Home" },
33
+ { slug: "fashion", name: "Fashion" },
34
+ { slug: "collectibles", name: "Collectibles" },
35
+ ] as const;
36
+
37
+ export const LISTING_SEEDS = [
38
+ {
39
+ slug: "electronics",
40
+ title: "Vintage analog synthesizer",
41
+ description: "Fully working 1980s synth with original case and power supply.",
42
+ priceCents: 42900,
43
+ quantity: 1,
44
+ shippingCents: 2500,
45
+ shippingDays: 5,
46
+ },
47
+ {
48
+ slug: "electronics",
49
+ title: "Wireless noise-cancelling headphones",
50
+ description: "Over-ear headphones with 30-hour battery and USB-C charging.",
51
+ priceCents: 8900,
52
+ quantity: 4,
53
+ shippingCents: 799,
54
+ shippingDays: 3,
55
+ },
56
+ {
57
+ slug: "electronics",
58
+ title: "Refurbished tablet 10-inch",
59
+ description: "Factory-reset tablet with charger. Light scuffs on the back.",
60
+ priceCents: 12900,
61
+ quantity: 2,
62
+ shippingCents: 995,
63
+ shippingDays: 4,
64
+ },
65
+ {
66
+ slug: "home",
67
+ title: "Cast iron skillet 12-inch",
68
+ description: "Pre-seasoned skillet. Heavy, even heat for stovetop and oven.",
69
+ priceCents: 4500,
70
+ quantity: 6,
71
+ shippingCents: 1200,
72
+ shippingDays: 4,
73
+ },
74
+ {
75
+ slug: "home",
76
+ title: "Linen duvet cover set",
77
+ description: "Queen size, stone-washed linen, includes two pillow shams.",
78
+ priceCents: 7800,
79
+ quantity: 3,
80
+ shippingCents: 899,
81
+ shippingDays: 5,
82
+ },
83
+ {
84
+ slug: "home",
85
+ title: "Ceramic pour-over coffee set",
86
+ description: "Dripper, carafe, and two mugs. Unused in original box.",
87
+ priceCents: 3600,
88
+ quantity: 5,
89
+ shippingCents: 650,
90
+ shippingDays: 3,
91
+ },
92
+ {
93
+ slug: "fashion",
94
+ title: "Wool overcoat, size M",
95
+ description: "Charcoal wool coat, worn twice. No stains or missing buttons.",
96
+ priceCents: 11500,
97
+ quantity: 1,
98
+ shippingCents: 1400,
99
+ shippingDays: 4,
100
+ },
101
+ {
102
+ slug: "fashion",
103
+ title: "Leather belt, 34-inch",
104
+ description: "Full-grain leather belt with brass buckle. Barely used.",
105
+ priceCents: 2800,
106
+ quantity: 3,
107
+ shippingCents: 499,
108
+ shippingDays: 2,
109
+ },
110
+ {
111
+ slug: "fashion",
112
+ title: "Canvas sneakers, size 42",
113
+ description: "Low-top canvas sneakers. Clean uppers, original laces.",
114
+ priceCents: 3200,
115
+ quantity: 2,
116
+ shippingCents: 599,
117
+ shippingDays: 3,
118
+ },
119
+ {
120
+ slug: "collectibles",
121
+ title: "First-print sci-fi paperback",
122
+ description: "1970s first printing. Tight spine, light shelf wear.",
123
+ priceCents: 5400,
124
+ quantity: 1,
125
+ shippingCents: 450,
126
+ shippingDays: 3,
127
+ },
128
+ {
129
+ slug: "collectibles",
130
+ title: "Enamel pin lot of 12",
131
+ description: "Assorted enamel pins from independent artists. All backed.",
132
+ priceCents: 1800,
133
+ quantity: 8,
134
+ shippingCents: 399,
135
+ shippingDays: 2,
136
+ },
137
+ {
138
+ slug: "collectibles",
139
+ title: "Framed concert poster",
140
+ description: "18x24 poster in a black frame. Ready to hang.",
141
+ priceCents: 6700,
142
+ quantity: 1,
143
+ shippingCents: 1800,
144
+ shippingDays: 6,
145
+ },
146
+ ] as const;
147
+
148
+ export type CategoryRow = InferSelectModel<typeof categories>;
149
+ export type ListingRow = InferSelectModel<typeof listings>;
150
+ export type CartItemRow = InferSelectModel<typeof cartItems>;
151
+ export type OrderRow = InferSelectModel<typeof orders>;
152
+ export type OrderItemRow = InferSelectModel<typeof orderItems>;
153
+ export type ReviewRow = InferSelectModel<typeof reviews>;
154
+
155
+ export type ImageDto = { id: string; url: string };
156
+ export type CategoryDto = { id: string; slug: string; name: string };
157
+
158
+ export type ListingDto = {
159
+ id: string;
160
+ sellerId: string;
161
+ sellerName?: string | null;
162
+ sellerEmail: string;
163
+ categoryId: string;
164
+ title: string;
165
+ description?: string | null;
166
+ priceCents: number;
167
+ quantity: number;
168
+ shippingCents: number;
169
+ shippingDays: number;
170
+ status: string;
171
+ createdAt: string;
172
+ updatedAt: string;
173
+ images: ImageDto[];
174
+ category: CategoryDto | null;
175
+ sellerAverageRating: number | null;
176
+ watchedByMe: boolean;
177
+ };
178
+
179
+ export type CartItemDto = {
180
+ id: string;
181
+ userId: string;
182
+ listingId: string;
183
+ quantity: number;
184
+ listing: ListingDto;
185
+ };
186
+
187
+ export type OrderItemDto = {
188
+ id: string;
189
+ orderId: string;
190
+ listingId: string;
191
+ title: string;
192
+ qty: number;
193
+ unitPriceCents: number;
194
+ };
195
+
196
+ export type OrderDto = {
197
+ id: string;
198
+ buyerId: string;
199
+ buyerEmail: string;
200
+ sellerId: string;
201
+ sellerEmail: string;
202
+ status: string;
203
+ shippingName: string;
204
+ shippingLine1: string;
205
+ shippingLine2?: string | null;
206
+ shippingCity: string;
207
+ shippingRegion?: string | null;
208
+ shippingPostal: string;
209
+ shippingCountry: string;
210
+ shippingCents: number;
211
+ totalCents: number;
212
+ last4?: string | null;
213
+ createdAt: string;
214
+ updatedAt: string;
215
+ items: OrderItemDto[];
216
+ };
217
+
218
+ export type ReviewDto = {
219
+ id: string;
220
+ listingId: string;
221
+ orderId: string;
222
+ buyerId: string;
223
+ rating: number;
224
+ body?: string | null;
225
+ createdAt: string;
226
+ };
227
+
228
+ type Db = ApiHandlerContext<unknown, unknown>["db"];
229
+
230
+ export function fail(status: number, message: string) {
231
+ return { status, body: { message } };
232
+ }
233
+
234
+ export function iso(value: Date | string | null | undefined): string | null {
235
+ if (!value) return null;
236
+ return value instanceof Date ? value.toISOString() : value;
237
+ }
238
+
239
+ export function requiredIso(value: Date | string | null | undefined): string {
240
+ return iso(value) ?? new Date().toISOString();
241
+ }
242
+
243
+ export function toInt(value: unknown, fallback = 0): number {
244
+ if (typeof value === "number" && Number.isFinite(value)) return Math.trunc(value);
245
+ if (typeof value === "string" && value.trim()) {
246
+ const parsed = Number(value);
247
+ if (Number.isFinite(parsed)) return Math.trunc(parsed);
248
+ }
249
+ return fallback;
250
+ }
251
+
252
+ export function parseOptionalInt(value: unknown): number | undefined {
253
+ if (value == null || value === "") return undefined;
254
+ const parsed = toInt(value, Number.NaN);
255
+ return Number.isFinite(parsed) ? parsed : undefined;
256
+ }
257
+
258
+ export function isTruthyQuery(value: unknown): boolean {
259
+ return value === true || value === "true" || value === "1" || value === 1;
260
+ }
261
+
262
+ export function paginate<T>(items: T[], page?: number, limit?: number) {
263
+ const currentPage = page && page > 0 ? page : 1;
264
+ const currentLimit = limit && limit > 0 ? Math.min(limit, 20) : 20;
265
+ const start = (currentPage - 1) * currentLimit;
266
+ const nextPage = currentPage * currentLimit < items.length ? currentPage + 1 : null;
267
+ return {
268
+ items: items.slice(start, start + currentLimit),
269
+ page: currentPage,
270
+ cursor: String(currentPage),
271
+ nextCursor: nextPage ? String(nextPage) : null,
272
+ limit: currentLimit,
273
+ totalItems: items.length,
274
+ };
275
+ }
276
+
277
+ export function isSeedRecipient(to: string, sellerId?: string | null) {
278
+ return to === SEED_SELLER_EMAIL || sellerId === SEED_SELLER_ID;
279
+ }
280
+
281
+ export async function sendMail(
282
+ email: EmailClient,
283
+ input: { to: string; subject: string; text: string; sellerId?: string | null },
284
+ ) {
285
+ if (isSeedRecipient(input.to, input.sellerId)) return;
286
+ await email.send({ to: input.to, subject: input.subject, text: input.text });
287
+ }
288
+
289
+ export async function ensureSeed(db: Db) {
290
+ const existingCategories = await db.select({ id: categories.id }).from(categories).limit(1);
291
+ if (existingCategories.length === 0) {
292
+ try {
293
+ await db.insert(categories).values(
294
+ CATEGORY_SEEDS.map((row) => ({
295
+ slug: row.slug,
296
+ name: row.name,
297
+ })),
298
+ );
299
+ } catch {
300
+ // First request won the seed race.
301
+ }
302
+ }
303
+
304
+ const existingListings = await db.select({ id: listings.id }).from(listings).limit(1);
305
+ if (existingListings.length > 0) return;
306
+
307
+ const categoryRows = await db.select().from(categories);
308
+ if (categoryRows.length === 0) return;
309
+ const bySlug = new Map(categoryRows.map((row) => [row.slug, row]));
310
+ const now = new Date();
311
+ try {
312
+ await db.insert(listings).values(
313
+ LISTING_SEEDS.flatMap((seed) => {
314
+ const category = bySlug.get(seed.slug);
315
+ if (!category) return [];
316
+ return [
317
+ {
318
+ sellerId: SEED_SELLER_ID,
319
+ sellerName: SEED_SELLER_NAME,
320
+ sellerEmail: SEED_SELLER_EMAIL,
321
+ categoryId: category.id,
322
+ title: seed.title,
323
+ description: seed.description,
324
+ priceCents: seed.priceCents,
325
+ quantity: seed.quantity,
326
+ shippingCents: seed.shippingCents,
327
+ shippingDays: seed.shippingDays,
328
+ status: LISTING_STATUS.active,
329
+ createdAt: now,
330
+ updatedAt: now,
331
+ },
332
+ ];
333
+ }),
334
+ );
335
+ } catch {
336
+ // First request won the seed race.
337
+ }
338
+ }
339
+
340
+ function sanitizeFilename(name: string): string {
341
+ const base = name.split(/[/\\]/).pop() ?? "";
342
+ return base
343
+ .replace(/[^A-Za-z0-9._-]+/g, "-")
344
+ .replace(/-+/g, "-")
345
+ .replace(/^-|-$/g, "")
346
+ .slice(0, 80);
347
+ }
348
+
349
+ export function listingUploadFiles(files: UploadedFile[] | undefined): UploadedFile[] {
350
+ const all = files ?? [];
351
+ const named = all.filter((file) => file.fieldname === "file" || file.fieldname === "files");
352
+ return (named.length > 0 ? named : all).filter((file) => file.data.length > 0);
353
+ }
354
+
355
+ export async function saveListingImage(
356
+ ctx: ApiHandlerContext<unknown, unknown>,
357
+ file: UploadedFile,
358
+ ): Promise<{ id: string } | ReturnType<typeof fail>> {
359
+ const mimeType = file.mimetype || "application/octet-stream";
360
+ if (file.data.length > MAX_LISTING_IMAGE_BYTES) {
361
+ return fail(400, "File is too large");
362
+ }
363
+ if (!ALLOWED_LISTING_IMAGE_TYPES.has(mimeType)) {
364
+ return fail(400, "Unsupported image type");
365
+ }
366
+
367
+ const fileName = file.filename || "image";
368
+ const safe = sanitizeFilename(fileName) || "image";
369
+ const key = `listing-images/${ctx.user!.id}/${crypto.randomUUID()}-${safe}`;
370
+ const [row] = await ctx.db
371
+ .insert(media)
372
+ .values({
373
+ key,
374
+ userId: ctx.user!.id,
375
+ resourceName: LISTING_IMAGE_RESOURCE,
376
+ fileName,
377
+ fileSize: file.data.length,
378
+ mimeType,
379
+ })
380
+ .returning();
381
+
382
+ try {
383
+ await ctx.storage.upload(row!.key, file.data, {
384
+ public: true,
385
+ contentType: mimeType,
386
+ });
387
+ } catch (error) {
388
+ return fail(503, error instanceof Error ? error.message : "Storage is not configured");
389
+ }
390
+
391
+ await ctx.db.update(media).set({ uploaded: new Date() }).where(eq(media.id, row!.id));
392
+ return { id: row!.id };
393
+ }
394
+
395
+ export async function resolveImage(
396
+ ctx: ApiHandlerContext<unknown, unknown>,
397
+ imageId: string,
398
+ ): Promise<ImageDto | null> {
399
+ const [row] = await ctx.db.select().from(media).where(eq(media.id, imageId)).limit(1);
400
+ if (!row?.uploaded) return null;
401
+ try {
402
+ const url = await ctx.storage.getUrl(row.key);
403
+ return { id: row.id, url };
404
+ } catch {
405
+ return { id: row.id, url: `/storage/objects/${encodeURIComponent(row.key)}` };
406
+ }
407
+ }
408
+
409
+ export async function findListing(db: Db, id: string) {
410
+ const [row] = await db.select().from(listings).where(eq(listings.id, id)).limit(1);
411
+ return row ?? null;
412
+ }
413
+
414
+ export async function findCategory(db: Db, id: string) {
415
+ const [row] = await db.select().from(categories).where(eq(categories.id, id)).limit(1);
416
+ return row ?? null;
417
+ }
418
+
419
+ export function isActiveForSale(listing: ListingRow) {
420
+ return listing.status === LISTING_STATUS.active && listing.quantity > 0;
421
+ }
422
+
423
+ export function browsePredicate(search?: string | null, categoryId?: string | null) {
424
+ const parts = [eq(listings.status, LISTING_STATUS.active), gt(listings.quantity, 0)];
425
+ if (categoryId) parts.push(eq(listings.categoryId, categoryId));
426
+ const term = search?.trim();
427
+ if (term) {
428
+ const pattern = `%${term}%`;
429
+ parts.push(or(ilike(listings.title, pattern), ilike(listings.description, pattern))!);
430
+ }
431
+ return and(...parts);
432
+ }
433
+
434
+ export async function enrichListings(
435
+ ctx: ApiHandlerContext<unknown, unknown>,
436
+ rows: ListingRow[],
437
+ currentUserId: string,
438
+ ): Promise<ListingDto[]> {
439
+ if (rows.length === 0) return [];
440
+ const listingIds = rows.map((row) => row.id);
441
+ const sellerIds = [...new Set(rows.map((row) => row.sellerId))];
442
+ const categoryIds = [...new Set(rows.map((row) => row.categoryId))];
443
+
444
+ const [categoryRows, imageRows, watchRows, sellerListingRows] = await Promise.all([
445
+ ctx.db.select().from(categories).where(inArray(categories.id, categoryIds)),
446
+ ctx.db
447
+ .select()
448
+ .from(listingImages)
449
+ .where(inArray(listingImages.listingId, listingIds))
450
+ .orderBy(asc(listingImages.sortOrder)),
451
+ ctx.db
452
+ .select()
453
+ .from(watches)
454
+ .where(and(eq(watches.userId, currentUserId), inArray(watches.listingId, listingIds))),
455
+ ctx.db
456
+ .select({ id: listings.id, sellerId: listings.sellerId })
457
+ .from(listings)
458
+ .where(inArray(listings.sellerId, sellerIds)),
459
+ ]);
460
+
461
+ const sellerListingIds = sellerListingRows.map((row) => row.id);
462
+ const reviewRows =
463
+ sellerListingIds.length === 0
464
+ ? []
465
+ : await ctx.db
466
+ .select({ listingId: reviews.listingId, rating: reviews.rating })
467
+ .from(reviews)
468
+ .where(inArray(reviews.listingId, sellerListingIds));
469
+
470
+ const categoryById = new Map(categoryRows.map((row) => [row.id, row]));
471
+ const watched = new Set(watchRows.map((row) => row.listingId));
472
+ const listingIdsBySeller = new Map<string, string[]>();
473
+ for (const row of sellerListingRows) {
474
+ const list = listingIdsBySeller.get(row.sellerId) ?? [];
475
+ list.push(row.id);
476
+ listingIdsBySeller.set(row.sellerId, list);
477
+ }
478
+ const ratingByListing = new Map<string, number[]>();
479
+ for (const row of reviewRows) {
480
+ const list = ratingByListing.get(row.listingId) ?? [];
481
+ list.push(row.rating);
482
+ ratingByListing.set(row.listingId, list);
483
+ }
484
+ const averageBySeller = new Map<string, number | null>();
485
+ for (const sellerId of sellerIds) {
486
+ const ids = listingIdsBySeller.get(sellerId) ?? [];
487
+ const ratings = ids.flatMap((id) => ratingByListing.get(id) ?? []);
488
+ averageBySeller.set(
489
+ sellerId,
490
+ ratings.length === 0
491
+ ? null
492
+ : Math.round((ratings.reduce((sum, value) => sum + value, 0) / ratings.length) * 10) / 10,
493
+ );
494
+ }
495
+
496
+ const imagesByListing = new Map<string, ImageDto[]>();
497
+ for (const image of imageRows) {
498
+ const resolved = await resolveImage(ctx, image.mediaId);
499
+ if (!resolved) continue;
500
+ const list = imagesByListing.get(image.listingId) ?? [];
501
+ list.push(resolved);
502
+ imagesByListing.set(image.listingId, list);
503
+ }
504
+
505
+ return rows.map((row) => {
506
+ const category = categoryById.get(row.categoryId);
507
+ return {
508
+ id: row.id,
509
+ sellerId: row.sellerId,
510
+ sellerName: row.sellerName,
511
+ sellerEmail: row.sellerEmail,
512
+ categoryId: row.categoryId,
513
+ title: row.title,
514
+ description: row.description,
515
+ priceCents: row.priceCents,
516
+ quantity: row.quantity,
517
+ shippingCents: row.shippingCents,
518
+ shippingDays: row.shippingDays,
519
+ status: row.status,
520
+ createdAt: requiredIso(row.createdAt),
521
+ updatedAt: requiredIso(row.updatedAt),
522
+ images: imagesByListing.get(row.id) ?? [],
523
+ category: category ? { id: category.id, slug: category.slug, name: category.name } : null,
524
+ sellerAverageRating: averageBySeller.get(row.sellerId) ?? null,
525
+ watchedByMe: watched.has(row.id),
526
+ };
527
+ });
528
+ }
529
+
530
+ export async function enrichOne(
531
+ ctx: ApiHandlerContext<unknown, unknown>,
532
+ listing: ListingRow,
533
+ currentUserId: string,
534
+ ) {
535
+ const [dto] = await enrichListings(ctx, [listing], currentUserId);
536
+ return dto!;
537
+ }
538
+
539
+ export async function replaceListingImages(
540
+ ctx: ApiHandlerContext<unknown, unknown>,
541
+ listingId: string,
542
+ files: UploadedFile[],
543
+ removeImageIds: string[],
544
+ existingCount: number,
545
+ ): Promise<ReturnType<typeof fail> | { added: number }> {
546
+ if (removeImageIds.length > 0) {
547
+ await ctx.db
548
+ .delete(listingImages)
549
+ .where(
550
+ and(eq(listingImages.listingId, listingId), inArray(listingImages.mediaId, removeImageIds)),
551
+ );
552
+ }
553
+ const remaining = await ctx.db
554
+ .select()
555
+ .from(listingImages)
556
+ .where(eq(listingImages.listingId, listingId));
557
+ if (remaining.length + files.length > MAX_LISTING_IMAGES) {
558
+ return fail(400, "Too many images");
559
+ }
560
+ let sortOrder = remaining.reduce((max, row) => Math.max(max, row.sortOrder), -1) + 1;
561
+ for (const file of files) {
562
+ const saved = await saveListingImage(ctx, file);
563
+ if (!("id" in saved)) return saved;
564
+ await ctx.db.insert(listingImages).values({
565
+ listingId,
566
+ mediaId: saved.id,
567
+ sortOrder,
568
+ });
569
+ sortOrder += 1;
570
+ }
571
+ return { added: files.length + existingCount };
572
+ }
573
+
574
+ export function parseRemoveImageIds(value: unknown): string[] {
575
+ if (Array.isArray(value)) {
576
+ return value.filter((item): item is string => typeof item === "string" && item.length > 0);
577
+ }
578
+ if (typeof value !== "string" || !value.trim()) return [];
579
+ const trimmed = value.trim();
580
+ if (trimmed.startsWith("[")) {
581
+ try {
582
+ const parsed = JSON.parse(trimmed) as unknown;
583
+ return Array.isArray(parsed)
584
+ ? parsed.filter((item): item is string => typeof item === "string" && item.length > 0)
585
+ : [];
586
+ } catch {
587
+ return [];
588
+ }
589
+ }
590
+ return trimmed
591
+ .split(",")
592
+ .map((item) => item.trim())
593
+ .filter(Boolean);
594
+ }
595
+
596
+ export function parseListingWrite(body: {
597
+ title: string;
598
+ description?: string;
599
+ categoryId: string;
600
+ priceCents: number;
601
+ quantity: number;
602
+ shippingCents: number;
603
+ shippingDays: number;
604
+ status?: string;
605
+ }) {
606
+ if (!body.title.trim()) return fail(400, "Title is required");
607
+ if (body.priceCents < 0 || body.quantity < 0 || body.shippingCents < 0 || body.shippingDays < 1) {
608
+ return fail(400, "Invalid listing amounts");
609
+ }
610
+ const status =
611
+ body.status === LISTING_STATUS.inactive ? LISTING_STATUS.inactive : LISTING_STATUS.active;
612
+ return {
613
+ title: body.title.trim(),
614
+ description: body.description?.trim() ? body.description.trim() : null,
615
+ categoryId: body.categoryId,
616
+ priceCents: body.priceCents,
617
+ quantity: body.quantity,
618
+ shippingCents: body.shippingCents,
619
+ shippingDays: body.shippingDays,
620
+ status,
621
+ };
622
+ }
623
+
624
+ export function toOrderDto(order: OrderRow, items: OrderItemRow[]): OrderDto {
625
+ return {
626
+ id: order.id,
627
+ buyerId: order.buyerId,
628
+ buyerEmail: order.buyerEmail,
629
+ sellerId: order.sellerId,
630
+ sellerEmail: order.sellerEmail,
631
+ status: order.status,
632
+ shippingName: order.shippingName,
633
+ shippingLine1: order.shippingLine1,
634
+ shippingLine2: order.shippingLine2,
635
+ shippingCity: order.shippingCity,
636
+ shippingRegion: order.shippingRegion,
637
+ shippingPostal: order.shippingPostal,
638
+ shippingCountry: order.shippingCountry,
639
+ shippingCents: order.shippingCents,
640
+ totalCents: order.totalCents,
641
+ last4: order.last4,
642
+ createdAt: requiredIso(order.createdAt),
643
+ updatedAt: requiredIso(order.updatedAt),
644
+ items: items.map((item) => ({
645
+ id: item.id,
646
+ orderId: item.orderId,
647
+ listingId: item.listingId,
648
+ title: item.title,
649
+ qty: item.qty,
650
+ unitPriceCents: item.unitPriceCents,
651
+ })),
652
+ };
653
+ }
654
+
655
+ export function toReviewDto(row: ReviewRow): ReviewDto {
656
+ return {
657
+ id: row.id,
658
+ listingId: row.listingId,
659
+ orderId: row.orderId,
660
+ buyerId: row.buyerId,
661
+ rating: row.rating,
662
+ body: row.body,
663
+ createdAt: requiredIso(row.createdAt),
664
+ };
665
+ }
666
+
667
+ export {
668
+ cartItems,
669
+ categories,
670
+ listingImages,
671
+ listings,
672
+ media,
673
+ orderItems,
674
+ orders,
675
+ reviews,
676
+ watches,
677
+ };