@pylonsync/create-pylon 0.3.274 → 0.3.276

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 (340) hide show
  1. package/bin/create-pylon.js +80 -0
  2. package/package.json +1 -1
  3. package/templates/ARCHETYPES.md +339 -0
  4. package/templates/agency/.env.example +12 -0
  5. package/templates/agency/AGENTS.md +61 -0
  6. package/templates/agency/README.md +90 -0
  7. package/templates/agency/app/auth-form.tsx +129 -0
  8. package/templates/agency/app/contact-form.tsx +258 -0
  9. package/templates/agency/app/dashboard/dashboard-client.tsx +1440 -0
  10. package/templates/agency/app/dashboard/page.tsx +70 -0
  11. package/templates/agency/app/error.tsx +26 -0
  12. package/templates/agency/app/globals.css +148 -0
  13. package/templates/agency/app/layout.tsx +174 -0
  14. package/templates/agency/app/login/page.tsx +39 -0
  15. package/templates/agency/app/not-found.tsx +19 -0
  16. package/templates/agency/app/page.tsx +249 -0
  17. package/templates/agency/app/robots.ts +12 -0
  18. package/templates/agency/app/seeder.tsx +26 -0
  19. package/templates/agency/app/sitemap.ts +9 -0
  20. package/templates/agency/app/work/[slug]/page.tsx +182 -0
  21. package/templates/agency/app/work/page.tsx +83 -0
  22. package/templates/agency/app.ts +284 -0
  23. package/templates/agency/components/marketing.tsx +187 -0
  24. package/templates/agency/components/section-scroller.tsx +35 -0
  25. package/templates/agency/components/ui/button.tsx +56 -0
  26. package/templates/agency/components/ui/card.tsx +90 -0
  27. package/templates/agency/components.json +20 -0
  28. package/templates/agency/functions/bookInquiry.ts +42 -0
  29. package/templates/agency/functions/clientsForOwner.ts +27 -0
  30. package/templates/agency/functions/declineInquiry.ts +41 -0
  31. package/templates/agency/functions/deleteClient.ts +27 -0
  32. package/templates/agency/functions/deleteInvoice.ts +19 -0
  33. package/templates/agency/functions/deleteProject.ts +20 -0
  34. package/templates/agency/functions/inquiriesForOwner.ts +31 -0
  35. package/templates/agency/functions/invoicesForOwner.ts +27 -0
  36. package/templates/agency/functions/seedCapacity.ts +26 -0
  37. package/templates/agency/functions/seedProjects.ts +41 -0
  38. package/templates/agency/functions/seedStudioBackoffice.ts +74 -0
  39. package/templates/agency/functions/setCapacity.ts +32 -0
  40. package/templates/agency/functions/setInvoiceStatus.ts +27 -0
  41. package/templates/agency/functions/setProjectFlags.ts +35 -0
  42. package/templates/agency/functions/submitInquiry.ts +55 -0
  43. package/templates/agency/functions/upsertClient.ts +73 -0
  44. package/templates/agency/functions/upsertInvoice.ts +113 -0
  45. package/templates/agency/functions/upsertProject.ts +97 -0
  46. package/templates/agency/gitignore +10 -0
  47. package/templates/agency/lib/agency.ts +189 -0
  48. package/templates/agency/lib/invoice-pdf.tsx +174 -0
  49. package/templates/agency/lib/owner.ts +26 -0
  50. package/templates/agency/lib/site.config.ts +418 -0
  51. package/templates/agency/lib/utils.ts +10 -0
  52. package/templates/agency/package.json +35 -0
  53. package/templates/agency/tsconfig.json +18 -0
  54. package/templates/ai-chat/.env.example +33 -0
  55. package/templates/ai-chat/AGENTS.md +61 -0
  56. package/templates/ai-chat/README.md +99 -0
  57. package/templates/ai-chat/app/auth-form.tsx +124 -0
  58. package/templates/ai-chat/app/chat-client.tsx +727 -0
  59. package/templates/ai-chat/app/error.tsx +26 -0
  60. package/templates/ai-chat/app/globals.css +148 -0
  61. package/templates/ai-chat/app/layout.tsx +75 -0
  62. package/templates/ai-chat/app/login/page.tsx +39 -0
  63. package/templates/ai-chat/app/not-found.tsx +19 -0
  64. package/templates/ai-chat/app/page.tsx +23 -0
  65. package/templates/ai-chat/app.ts +121 -0
  66. package/templates/ai-chat/components.json +20 -0
  67. package/templates/ai-chat/functions/deleteConversation.ts +33 -0
  68. package/templates/ai-chat/gitignore +10 -0
  69. package/templates/ai-chat/lib/site.config.ts +103 -0
  70. package/templates/ai-chat/lib/utils.ts +10 -0
  71. package/templates/ai-chat/package.json +34 -0
  72. package/templates/ai-chat/tsconfig.json +18 -0
  73. package/templates/ai-studio/.env.example +19 -0
  74. package/templates/ai-studio/AGENTS.md +61 -0
  75. package/templates/ai-studio/README.md +83 -0
  76. package/templates/ai-studio/app/auth-form.tsx +124 -0
  77. package/templates/ai-studio/app/error.tsx +26 -0
  78. package/templates/ai-studio/app/globals.css +148 -0
  79. package/templates/ai-studio/app/layout.tsx +75 -0
  80. package/templates/ai-studio/app/login/page.tsx +39 -0
  81. package/templates/ai-studio/app/not-found.tsx +19 -0
  82. package/templates/ai-studio/app/page.tsx +34 -0
  83. package/templates/ai-studio/app/studio-client.tsx +357 -0
  84. package/templates/ai-studio/app.ts +108 -0
  85. package/templates/ai-studio/components.json +20 -0
  86. package/templates/ai-studio/functions/_getGeneration.ts +25 -0
  87. package/templates/ai-studio/functions/_updateGeneration.ts +37 -0
  88. package/templates/ai-studio/functions/generate.ts +42 -0
  89. package/templates/ai-studio/functions/pollGeneration.ts +134 -0
  90. package/templates/ai-studio/gitignore +10 -0
  91. package/templates/ai-studio/lib/site.config.ts +80 -0
  92. package/templates/ai-studio/lib/studio.ts +52 -0
  93. package/templates/ai-studio/lib/utils.ts +10 -0
  94. package/templates/ai-studio/package.json +34 -0
  95. package/templates/ai-studio/tsconfig.json +18 -0
  96. package/templates/creator/.env.example +12 -0
  97. package/templates/creator/AGENTS.md +61 -0
  98. package/templates/creator/README.md +67 -0
  99. package/templates/creator/app/auth-form.tsx +129 -0
  100. package/templates/creator/app/dashboard/dashboard-client.tsx +297 -0
  101. package/templates/creator/app/dashboard/page.tsx +70 -0
  102. package/templates/creator/app/error.tsx +26 -0
  103. package/templates/creator/app/globals.css +148 -0
  104. package/templates/creator/app/layout.tsx +160 -0
  105. package/templates/creator/app/login/page.tsx +39 -0
  106. package/templates/creator/app/newsletter-signup.tsx +162 -0
  107. package/templates/creator/app/not-found.tsx +19 -0
  108. package/templates/creator/app/page.tsx +160 -0
  109. package/templates/creator/app/robots.ts +12 -0
  110. package/templates/creator/app/sitemap.ts +9 -0
  111. package/templates/creator/app.ts +134 -0
  112. package/templates/creator/components/marketing.tsx +148 -0
  113. package/templates/creator/components/section-scroller.tsx +35 -0
  114. package/templates/creator/components/ui/button.tsx +56 -0
  115. package/templates/creator/components/ui/card.tsx +90 -0
  116. package/templates/creator/components.json +20 -0
  117. package/templates/creator/functions/subscribe.ts +82 -0
  118. package/templates/creator/functions/subscriberStats.ts +75 -0
  119. package/templates/creator/gitignore +10 -0
  120. package/templates/creator/lib/owner.ts +26 -0
  121. package/templates/creator/lib/site.config.ts +173 -0
  122. package/templates/creator/lib/stats.ts +30 -0
  123. package/templates/creator/lib/utils.ts +10 -0
  124. package/templates/creator/package.json +34 -0
  125. package/templates/creator/tsconfig.json +18 -0
  126. package/templates/default/app/layout.tsx +26 -27
  127. package/templates/default/app/page.tsx +90 -274
  128. package/templates/default/lib/products.ts +9 -122
  129. package/templates/default/lib/site.config.ts +739 -0
  130. package/templates/default/lib/site.ts +14 -261
  131. package/templates/directory/.env.example +12 -0
  132. package/templates/directory/AGENTS.md +61 -0
  133. package/templates/directory/README.md +80 -0
  134. package/templates/directory/app/auth-form.tsx +129 -0
  135. package/templates/directory/app/dashboard/dashboard-client.tsx +205 -0
  136. package/templates/directory/app/dashboard/page.tsx +70 -0
  137. package/templates/directory/app/directory-browse.tsx +328 -0
  138. package/templates/directory/app/error.tsx +26 -0
  139. package/templates/directory/app/globals.css +148 -0
  140. package/templates/directory/app/layout.tsx +171 -0
  141. package/templates/directory/app/login/page.tsx +39 -0
  142. package/templates/directory/app/not-found.tsx +19 -0
  143. package/templates/directory/app/page.tsx +50 -0
  144. package/templates/directory/app/robots.ts +12 -0
  145. package/templates/directory/app/sitemap.ts +9 -0
  146. package/templates/directory/app/submit/page.tsx +30 -0
  147. package/templates/directory/app/submit-form.tsx +151 -0
  148. package/templates/directory/app.ts +146 -0
  149. package/templates/directory/components/marketing.tsx +148 -0
  150. package/templates/directory/components/section-scroller.tsx +35 -0
  151. package/templates/directory/components/ui/button.tsx +56 -0
  152. package/templates/directory/components/ui/card.tsx +90 -0
  153. package/templates/directory/components.json +20 -0
  154. package/templates/directory/functions/approveSubmission.ts +45 -0
  155. package/templates/directory/functions/rejectSubmission.ts +20 -0
  156. package/templates/directory/functions/seedListings.ts +33 -0
  157. package/templates/directory/functions/submissionsForOwner.ts +29 -0
  158. package/templates/directory/functions/submitListing.ts +63 -0
  159. package/templates/directory/functions/upvote.ts +24 -0
  160. package/templates/directory/gitignore +10 -0
  161. package/templates/directory/lib/directory.ts +45 -0
  162. package/templates/directory/lib/owner.ts +26 -0
  163. package/templates/directory/lib/site.config.ts +130 -0
  164. package/templates/directory/lib/utils.ts +10 -0
  165. package/templates/directory/package.json +34 -0
  166. package/templates/directory/tsconfig.json +18 -0
  167. package/templates/local-service/.env.example +12 -0
  168. package/templates/local-service/AGENTS.md +61 -0
  169. package/templates/local-service/README.md +82 -0
  170. package/templates/local-service/app/auth-form.tsx +129 -0
  171. package/templates/local-service/app/booking-widget.tsx +399 -0
  172. package/templates/local-service/app/dashboard/dashboard-client.tsx +304 -0
  173. package/templates/local-service/app/dashboard/page.tsx +63 -0
  174. package/templates/local-service/app/error.tsx +26 -0
  175. package/templates/local-service/app/globals.css +148 -0
  176. package/templates/local-service/app/layout.tsx +151 -0
  177. package/templates/local-service/app/login/page.tsx +39 -0
  178. package/templates/local-service/app/not-found.tsx +19 -0
  179. package/templates/local-service/app/page.tsx +233 -0
  180. package/templates/local-service/app/robots.ts +12 -0
  181. package/templates/local-service/app/sitemap.ts +9 -0
  182. package/templates/local-service/app.ts +131 -0
  183. package/templates/local-service/components/marketing.tsx +162 -0
  184. package/templates/local-service/components/section-scroller.tsx +35 -0
  185. package/templates/local-service/components/ui/button.tsx +56 -0
  186. package/templates/local-service/components/ui/card.tsx +90 -0
  187. package/templates/local-service/components.json +20 -0
  188. package/templates/local-service/functions/bookingsForOwner.ts +30 -0
  189. package/templates/local-service/functions/cancelBooking.ts +27 -0
  190. package/templates/local-service/functions/confirmBooking.ts +18 -0
  191. package/templates/local-service/functions/createBooking.ts +98 -0
  192. package/templates/local-service/gitignore +10 -0
  193. package/templates/local-service/lib/booking.ts +24 -0
  194. package/templates/local-service/lib/owner.ts +26 -0
  195. package/templates/local-service/lib/site.config.ts +232 -0
  196. package/templates/local-service/lib/slots.ts +97 -0
  197. package/templates/local-service/lib/utils.ts +10 -0
  198. package/templates/local-service/package.json +34 -0
  199. package/templates/local-service/tsconfig.json +18 -0
  200. package/templates/marketplace/.env.example +9 -0
  201. package/templates/marketplace/AGENTS.md +61 -0
  202. package/templates/marketplace/README.md +78 -0
  203. package/templates/marketplace/app/_components/CategoryIcon.tsx +40 -0
  204. package/templates/marketplace/app/error.tsx +26 -0
  205. package/templates/marketplace/app/globals.css +64 -0
  206. package/templates/marketplace/app/layout.tsx +60 -0
  207. package/templates/marketplace/app/listing/[id]/page.tsx +163 -0
  208. package/templates/marketplace/app/me/page.tsx +15 -0
  209. package/templates/marketplace/app/not-found.tsx +20 -0
  210. package/templates/marketplace/app/page.tsx +159 -0
  211. package/templates/marketplace/app/robots.ts +12 -0
  212. package/templates/marketplace/app/sell/page.tsx +26 -0
  213. package/templates/marketplace/app/sitemap.ts +14 -0
  214. package/templates/marketplace/app.ts +190 -0
  215. package/templates/marketplace/client/AuthNav.tsx +46 -0
  216. package/templates/marketplace/client/LiveTicker.tsx +104 -0
  217. package/templates/marketplace/client/LoginCard.tsx +130 -0
  218. package/templates/marketplace/client/MarketProvider.tsx +148 -0
  219. package/templates/marketplace/client/MyMarket.tsx +180 -0
  220. package/templates/marketplace/client/OfferPanel.tsx +355 -0
  221. package/templates/marketplace/client/SeedOnEmpty.tsx +26 -0
  222. package/templates/marketplace/client/SellForm.tsx +160 -0
  223. package/templates/marketplace/client/WatchButton.tsx +88 -0
  224. package/templates/marketplace/client/market.ts +341 -0
  225. package/templates/marketplace/functions/buyNow.ts +78 -0
  226. package/templates/marketplace/functions/makeOffer.ts +65 -0
  227. package/templates/marketplace/functions/respondToOffer.ts +62 -0
  228. package/templates/marketplace/functions/seedMarket.ts +90 -0
  229. package/templates/marketplace/gitignore +10 -0
  230. package/templates/marketplace/package.json +35 -0
  231. package/templates/marketplace/tsconfig.json +14 -0
  232. package/templates/marketplace/ui/badge.tsx +30 -0
  233. package/templates/marketplace/ui/button.tsx +49 -0
  234. package/templates/marketplace/ui/card.tsx +48 -0
  235. package/templates/marketplace/ui/input.tsx +17 -0
  236. package/templates/marketplace/ui/label.tsx +18 -0
  237. package/templates/marketplace/ui/textarea.tsx +17 -0
  238. package/templates/marketplace/ui/tokens.css +32 -0
  239. package/templates/marketplace/ui/utils.ts +6 -0
  240. package/templates/restaurant/.env.example +12 -0
  241. package/templates/restaurant/AGENTS.md +61 -0
  242. package/templates/restaurant/README.md +77 -0
  243. package/templates/restaurant/app/auth-form.tsx +129 -0
  244. package/templates/restaurant/app/dashboard/dashboard-client.tsx +263 -0
  245. package/templates/restaurant/app/dashboard/page.tsx +59 -0
  246. package/templates/restaurant/app/error.tsx +26 -0
  247. package/templates/restaurant/app/globals.css +148 -0
  248. package/templates/restaurant/app/layout.tsx +151 -0
  249. package/templates/restaurant/app/login/page.tsx +39 -0
  250. package/templates/restaurant/app/not-found.tsx +19 -0
  251. package/templates/restaurant/app/page.tsx +194 -0
  252. package/templates/restaurant/app/reservation-widget.tsx +359 -0
  253. package/templates/restaurant/app/robots.ts +12 -0
  254. package/templates/restaurant/app/sitemap.ts +9 -0
  255. package/templates/restaurant/app.ts +115 -0
  256. package/templates/restaurant/components/marketing.tsx +162 -0
  257. package/templates/restaurant/components/section-scroller.tsx +35 -0
  258. package/templates/restaurant/components/ui/button.tsx +56 -0
  259. package/templates/restaurant/components/ui/card.tsx +90 -0
  260. package/templates/restaurant/components.json +20 -0
  261. package/templates/restaurant/functions/cancelReservation.ts +26 -0
  262. package/templates/restaurant/functions/confirmReservation.ts +17 -0
  263. package/templates/restaurant/functions/createReservation.ts +92 -0
  264. package/templates/restaurant/functions/reservationsForOwner.ts +28 -0
  265. package/templates/restaurant/gitignore +10 -0
  266. package/templates/restaurant/lib/owner.ts +26 -0
  267. package/templates/restaurant/lib/reservation.ts +22 -0
  268. package/templates/restaurant/lib/site.config.ts +218 -0
  269. package/templates/restaurant/lib/slots.ts +55 -0
  270. package/templates/restaurant/lib/utils.ts +10 -0
  271. package/templates/restaurant/package.json +34 -0
  272. package/templates/restaurant/tsconfig.json +18 -0
  273. package/templates/shop/.env.example +32 -0
  274. package/templates/shop/AGENTS.md +61 -0
  275. package/templates/shop/README.md +102 -0
  276. package/templates/shop/app/auth-form.tsx +129 -0
  277. package/templates/shop/app/dashboard/dashboard-client.tsx +264 -0
  278. package/templates/shop/app/dashboard/page.tsx +59 -0
  279. package/templates/shop/app/error.tsx +26 -0
  280. package/templates/shop/app/globals.css +148 -0
  281. package/templates/shop/app/layout.tsx +160 -0
  282. package/templates/shop/app/login/page.tsx +39 -0
  283. package/templates/shop/app/not-found.tsx +19 -0
  284. package/templates/shop/app/page.tsx +95 -0
  285. package/templates/shop/app/robots.ts +12 -0
  286. package/templates/shop/app/shop-client.tsx +436 -0
  287. package/templates/shop/app/sitemap.ts +9 -0
  288. package/templates/shop/app/success/page.tsx +33 -0
  289. package/templates/shop/app.ts +134 -0
  290. package/templates/shop/components/marketing.tsx +96 -0
  291. package/templates/shop/components/section-scroller.tsx +35 -0
  292. package/templates/shop/components/ui/button.tsx +56 -0
  293. package/templates/shop/components/ui/card.tsx +90 -0
  294. package/templates/shop/components.json +20 -0
  295. package/templates/shop/functions/cancelOrder.ts +33 -0
  296. package/templates/shop/functions/checkout.ts +130 -0
  297. package/templates/shop/functions/fulfillOrder.ts +17 -0
  298. package/templates/shop/functions/markGroupPaid.ts +26 -0
  299. package/templates/shop/functions/ordersForOwner.ts +28 -0
  300. package/templates/shop/functions/releaseGroup.ts +36 -0
  301. package/templates/shop/functions/reserveCart.ts +87 -0
  302. package/templates/shop/functions/restockProduct.ts +23 -0
  303. package/templates/shop/functions/seedProducts.ts +30 -0
  304. package/templates/shop/functions/stripeWebhook.ts +72 -0
  305. package/templates/shop/gitignore +10 -0
  306. package/templates/shop/lib/owner.ts +26 -0
  307. package/templates/shop/lib/shop.ts +45 -0
  308. package/templates/shop/lib/site.config.ts +198 -0
  309. package/templates/shop/lib/utils.ts +10 -0
  310. package/templates/shop/package.json +35 -0
  311. package/templates/shop/tsconfig.json +18 -0
  312. package/templates/waitlist/.env.example +12 -0
  313. package/templates/waitlist/AGENTS.md +61 -0
  314. package/templates/waitlist/README.md +81 -0
  315. package/templates/waitlist/app/auth-form.tsx +129 -0
  316. package/templates/waitlist/app/dashboard/dashboard-client.tsx +297 -0
  317. package/templates/waitlist/app/dashboard/page.tsx +70 -0
  318. package/templates/waitlist/app/error.tsx +26 -0
  319. package/templates/waitlist/app/globals.css +148 -0
  320. package/templates/waitlist/app/layout.tsx +158 -0
  321. package/templates/waitlist/app/login/page.tsx +39 -0
  322. package/templates/waitlist/app/not-found.tsx +19 -0
  323. package/templates/waitlist/app/page.tsx +119 -0
  324. package/templates/waitlist/app/robots.ts +12 -0
  325. package/templates/waitlist/app/sitemap.ts +9 -0
  326. package/templates/waitlist/app/waitlist-hero.tsx +219 -0
  327. package/templates/waitlist/app.ts +134 -0
  328. package/templates/waitlist/components/marketing.tsx +96 -0
  329. package/templates/waitlist/components/ui/button.tsx +56 -0
  330. package/templates/waitlist/components/ui/card.tsx +90 -0
  331. package/templates/waitlist/components.json +20 -0
  332. package/templates/waitlist/functions/joinWaitlist.ts +82 -0
  333. package/templates/waitlist/functions/waitlistStats.ts +75 -0
  334. package/templates/waitlist/gitignore +10 -0
  335. package/templates/waitlist/lib/owner.ts +26 -0
  336. package/templates/waitlist/lib/site.config.ts +178 -0
  337. package/templates/waitlist/lib/stats.ts +30 -0
  338. package/templates/waitlist/lib/utils.ts +10 -0
  339. package/templates/waitlist/package.json +34 -0
  340. package/templates/waitlist/tsconfig.json +18 -0
@@ -0,0 +1,42 @@
1
+ import { mutation, v } from "@pylonsync/functions";
2
+ import { emailMatchesOwner } from "../lib/owner";
3
+
4
+ // bookInquiry — owner-only. Marks a lead "booked" AND consumes one project slot:
5
+ // it decrements Capacity.openSlots, which syncs to every open landing page, so
6
+ // the hero's "N slots open" counter ticks down live for everyone. Taking a
7
+ // per-row advisory lock on the capacity row makes the read-then-decrement
8
+ // race-safe. Idempotent: booking an already-booked lead doesn't double-count.
9
+ export default mutation<{ inquiryId: string }, { ok: boolean; openSlots: number }>({
10
+ auth: "user",
11
+ args: { inquiryId: v.id("Inquiry") },
12
+ async handler(ctx, args) {
13
+ const me = await ctx.db.get("User", ctx.auth.userId);
14
+ if (!emailMatchesOwner(me?.email as string | undefined, ctx.env.PYLON_OWNER_EMAIL)) {
15
+ throw ctx.error("POLICY_DENIED", "Only the owner can manage inquiries.");
16
+ }
17
+
18
+ const inquiry = (await ctx.db.get("Inquiry", args.inquiryId)) as
19
+ | { status: string }
20
+ | null;
21
+ if (!inquiry) throw ctx.error("NOT_FOUND", "Inquiry not found.");
22
+
23
+ await ctx.db.advisoryLock("agency_capacity");
24
+ const cap = ((await ctx.db.unsafe.list("Capacity")) as unknown as {
25
+ id: string;
26
+ openSlots: number;
27
+ }[])[0];
28
+ let openSlots = cap?.openSlots ?? 0;
29
+
30
+ // Only decrement when this lead is newly booked — re-booking is a no-op.
31
+ if (inquiry.status !== "booked" && cap) {
32
+ openSlots = Math.max(0, cap.openSlots - 1);
33
+ await ctx.db.unsafe.update("Capacity", cap.id, {
34
+ openSlots,
35
+ updatedAt: new Date().toISOString(),
36
+ });
37
+ }
38
+ await ctx.db.unsafe.update("Inquiry", args.inquiryId, { status: "booked" });
39
+
40
+ return { ok: true, openSlots };
41
+ },
42
+ });
@@ -0,0 +1,27 @@
1
+ import { query } from "@pylonsync/functions";
2
+ import { emailMatchesOwner } from "../lib/owner";
3
+ import type { ClientRow, OwnerClientsResult } from "../lib/agency";
4
+
5
+ // clientsForOwner — the owner's CRM. Client rows hold contact PII (name, email,
6
+ // phone), so the entity denies all client access; this owner-gated query is the
7
+ // only way to read them. A query has no `ctx.error`, so a non-owner gets
8
+ // `{ authorized: false }` (a bare throw would surface as a stripped
9
+ // HANDLER_ERROR) and NO data. The dashboard calls it with `callFn` and refetches
10
+ // after any write.
11
+ export default query({
12
+ auth: "user",
13
+ async handler(ctx): Promise<OwnerClientsResult> {
14
+ const me = await ctx.db.get("User", ctx.auth.userId);
15
+ const email = (me?.email as string | undefined) ?? null;
16
+ if (!emailMatchesOwner(email, ctx.env.PYLON_OWNER_EMAIL)) {
17
+ return { authorized: false };
18
+ }
19
+
20
+ const rows = (await ctx.db.unsafe.list("Client")) as unknown as ClientRow[];
21
+ const clients = rows
22
+ .map((r) => ({ ...r }))
23
+ .sort((a, b) => (a.createdAt < b.createdAt ? 1 : a.createdAt > b.createdAt ? -1 : 0));
24
+
25
+ return { authorized: true, clients };
26
+ },
27
+ });
@@ -0,0 +1,41 @@
1
+ import { mutation, v } from "@pylonsync/functions";
2
+ import { emailMatchesOwner } from "../lib/owner";
3
+
4
+ // declineInquiry — owner-only. Marks a lead "declined". If it had been "booked",
5
+ // it returns the project slot to the pool (Capacity.openSlots += 1), which syncs
6
+ // live so the hero counter ticks back up everywhere. Race-safe via the capacity
7
+ // advisory lock; idempotent on an already-declined lead.
8
+ export default mutation<{ inquiryId: string }, { ok: boolean; openSlots: number }>({
9
+ auth: "user",
10
+ args: { inquiryId: v.id("Inquiry") },
11
+ async handler(ctx, args) {
12
+ const me = await ctx.db.get("User", ctx.auth.userId);
13
+ if (!emailMatchesOwner(me?.email as string | undefined, ctx.env.PYLON_OWNER_EMAIL)) {
14
+ throw ctx.error("POLICY_DENIED", "Only the owner can manage inquiries.");
15
+ }
16
+
17
+ const inquiry = (await ctx.db.get("Inquiry", args.inquiryId)) as
18
+ | { status: string }
19
+ | null;
20
+ if (!inquiry) throw ctx.error("NOT_FOUND", "Inquiry not found.");
21
+
22
+ await ctx.db.advisoryLock("agency_capacity");
23
+ const cap = ((await ctx.db.unsafe.list("Capacity")) as unknown as {
24
+ id: string;
25
+ openSlots: number;
26
+ }[])[0];
27
+ let openSlots = cap?.openSlots ?? 0;
28
+
29
+ // Releasing a previously-booked lead frees its slot again.
30
+ if (inquiry.status === "booked" && cap) {
31
+ openSlots = cap.openSlots + 1;
32
+ await ctx.db.unsafe.update("Capacity", cap.id, {
33
+ openSlots,
34
+ updatedAt: new Date().toISOString(),
35
+ });
36
+ }
37
+ await ctx.db.unsafe.update("Inquiry", args.inquiryId, { status: "declined" });
38
+
39
+ return { ok: true, openSlots };
40
+ },
41
+ });
@@ -0,0 +1,27 @@
1
+ import { mutation, v } from "@pylonsync/functions";
2
+ import { emailMatchesOwner } from "../lib/owner";
3
+ import type { InvoiceRow } from "../lib/agency";
4
+
5
+ // deleteClient — owner-only. Refuses to delete a client that still has invoices
6
+ // (a financial record must not lose its counterparty); the owner deletes or
7
+ // reassigns those invoices first. Returns `{ ok: false, invoices: n }` so the
8
+ // dashboard can explain why.
9
+ export default mutation<{ id: string }, { ok: boolean; invoices: number }>({
10
+ auth: "user",
11
+ args: { id: v.id("Client") },
12
+ async handler(ctx, args) {
13
+ const me = await ctx.db.get("User", ctx.auth.userId);
14
+ if (!emailMatchesOwner(me?.email as string | undefined, ctx.env.PYLON_OWNER_EMAIL)) {
15
+ throw ctx.error("POLICY_DENIED", "Only the owner can manage clients.");
16
+ }
17
+ const client = await ctx.db.get("Client", args.id);
18
+ if (!client) return { ok: true, invoices: 0 };
19
+
20
+ const invoices = (await ctx.db.unsafe.list("Invoice")) as unknown as InvoiceRow[];
21
+ const linked = invoices.filter((i) => i.clientId === args.id).length;
22
+ if (linked > 0) return { ok: false, invoices: linked };
23
+
24
+ await ctx.db.unsafe.delete("Client", args.id);
25
+ return { ok: true, invoices: 0 };
26
+ },
27
+ });
@@ -0,0 +1,19 @@
1
+ import { mutation, v } from "@pylonsync/functions";
2
+ import { emailMatchesOwner } from "../lib/owner";
3
+
4
+ // deleteInvoice — owner-only. Removes a bill outright (e.g. a draft created in
5
+ // error). No cascade — invoices don't own anything.
6
+ export default mutation<{ id: string }, { ok: boolean }>({
7
+ auth: "user",
8
+ args: { id: v.id("Invoice") },
9
+ async handler(ctx, args) {
10
+ const me = await ctx.db.get("User", ctx.auth.userId);
11
+ if (!emailMatchesOwner(me?.email as string | undefined, ctx.env.PYLON_OWNER_EMAIL)) {
12
+ throw ctx.error("POLICY_DENIED", "Only the owner can manage invoices.");
13
+ }
14
+ const invoice = await ctx.db.get("Invoice", args.id);
15
+ if (!invoice) return { ok: true };
16
+ await ctx.db.unsafe.delete("Invoice", args.id);
17
+ return { ok: true };
18
+ },
19
+ });
@@ -0,0 +1,20 @@
1
+ import { mutation, v } from "@pylonsync/functions";
2
+ import { emailMatchesOwner } from "../lib/owner";
3
+
4
+ // deleteProject — owner-only. Removes a case study. Any invoice that referenced
5
+ // it keeps its denormalized `projectTitle` (so the bill still reads sensibly),
6
+ // it just no longer links anywhere — we don't cascade-delete money records.
7
+ export default mutation<{ id: string }, { ok: boolean }>({
8
+ auth: "user",
9
+ args: { id: v.id("Project") },
10
+ async handler(ctx, args) {
11
+ const me = await ctx.db.get("User", ctx.auth.userId);
12
+ if (!emailMatchesOwner(me?.email as string | undefined, ctx.env.PYLON_OWNER_EMAIL)) {
13
+ throw ctx.error("POLICY_DENIED", "Only the owner can manage projects.");
14
+ }
15
+ const project = await ctx.db.get("Project", args.id);
16
+ if (!project) return { ok: true };
17
+ await ctx.db.unsafe.delete("Project", args.id);
18
+ return { ok: true };
19
+ },
20
+ });
@@ -0,0 +1,31 @@
1
+ import { query } from "@pylonsync/functions";
2
+ import { emailMatchesOwner } from "../lib/owner";
3
+ import type { InquiryRow, OwnerInquiriesResult } from "../lib/agency";
4
+
5
+ // inquiriesForOwner — the owner's view of every project lead, INCLUDING the
6
+ // prospect's name, email, company, and budget. The one function allowed to
7
+ // return that PII, gated to the configured owner (PYLON_OWNER_EMAIL via
8
+ // ctx.env). A query has no `ctx.error`, so a non-owner gets `{ authorized:
9
+ // false }` (a bare throw would surface as a stripped HANDLER_ERROR) and NO data.
10
+ //
11
+ // The dashboard calls it with `callFn` and re-fetches whenever the public
12
+ // Capacity row changes — so booking a lead (which moves the counter) refreshes
13
+ // the pipeline without a page reload, while contact details never travel over
14
+ // entity sync.
15
+ export default query({
16
+ auth: "user",
17
+ async handler(ctx): Promise<OwnerInquiriesResult> {
18
+ const me = await ctx.db.get("User", ctx.auth.userId);
19
+ const email = (me?.email as string | undefined) ?? null;
20
+ if (!emailMatchesOwner(email, ctx.env.PYLON_OWNER_EMAIL)) {
21
+ return { authorized: false };
22
+ }
23
+
24
+ const rows = (await ctx.db.unsafe.list("Inquiry")) as unknown as InquiryRow[];
25
+ const inquiries = rows
26
+ .map((r) => ({ ...r }))
27
+ .sort((a, b) => (a.createdAt < b.createdAt ? 1 : a.createdAt > b.createdAt ? -1 : 0));
28
+
29
+ return { authorized: true, inquiries };
30
+ },
31
+ });
@@ -0,0 +1,27 @@
1
+ import { query } from "@pylonsync/functions";
2
+ import { emailMatchesOwner } from "../lib/owner";
3
+ import type { InvoiceRow, OwnerInvoicesResult } from "../lib/agency";
4
+
5
+ // invoicesForOwner — the owner's billing. Invoices hold money + client data, so
6
+ // the entity denies all client access; this owner-gated query is the only way
7
+ // to read them. Like the other owner queries it returns `{ authorized: false }`
8
+ // for a non-owner (a query has no `ctx.error`) rather than throwing. Sorted
9
+ // newest-first by issue date, falling back to creation time.
10
+ export default query({
11
+ auth: "user",
12
+ async handler(ctx): Promise<OwnerInvoicesResult> {
13
+ const me = await ctx.db.get("User", ctx.auth.userId);
14
+ const email = (me?.email as string | undefined) ?? null;
15
+ if (!emailMatchesOwner(email, ctx.env.PYLON_OWNER_EMAIL)) {
16
+ return { authorized: false };
17
+ }
18
+
19
+ const rows = (await ctx.db.unsafe.list("Invoice")) as unknown as InvoiceRow[];
20
+ const key = (i: InvoiceRow) => i.issuedAt || i.createdAt;
21
+ const invoices = rows
22
+ .map((r) => ({ ...r }))
23
+ .sort((a, b) => (key(a) < key(b) ? 1 : key(a) > key(b) ? -1 : 0));
24
+
25
+ return { authorized: true, invoices };
26
+ },
27
+ });
@@ -0,0 +1,26 @@
1
+ import { mutation } from "@pylonsync/functions";
2
+ import { siteConfig } from "../lib/site.config";
3
+
4
+ // seedCapacity — create the single Capacity row from config on first visit
5
+ // (idempotent). The landing page calls this on mount; once a row exists it's a
6
+ // no-op, so it's safe to call on every load. A lock keeps two concurrent
7
+ // first-visits from creating two rows.
8
+ //
9
+ // Public so an anonymous first visitor seeds it — it only ever writes the
10
+ // config's booking window + open-slot count, never reads or returns anything
11
+ // sensitive.
12
+ export default mutation<Record<string, never>, { seeded: boolean }>({
13
+ auth: "public",
14
+ async handler(ctx) {
15
+ await ctx.db.advisoryLock("agency_seed_capacity");
16
+ const existing = await ctx.db.unsafe.list("Capacity");
17
+ if (existing.length > 0) return { seeded: false };
18
+
19
+ await ctx.db.unsafe.insert("Capacity", {
20
+ label: siteConfig.capacity.label,
21
+ openSlots: siteConfig.capacity.openSlots,
22
+ updatedAt: new Date().toISOString(),
23
+ });
24
+ return { seeded: true };
25
+ },
26
+ });
@@ -0,0 +1,41 @@
1
+ import { mutation } from "@pylonsync/functions";
2
+ import { siteConfig } from "../lib/site.config";
3
+ import { slugify } from "../lib/agency";
4
+
5
+ // seedProjects — create the portfolio from config on first visit (idempotent).
6
+ // The public pages call it on mount; once any Project row exists it's a no-op,
7
+ // so it's safe to call on every load. A lock keeps two concurrent first-visits
8
+ // from double-seeding.
9
+ //
10
+ // Public so an anonymous first visitor seeds it: Projects are public marketing
11
+ // content (no PII), and this only ever writes the config's case studies.
12
+ export default mutation<Record<string, never>, { seeded: number }>({
13
+ auth: "public",
14
+ async handler(ctx) {
15
+ await ctx.db.advisoryLock("agency_seed_projects");
16
+ const existing = await ctx.db.unsafe.list("Project");
17
+ if (existing.length > 0) return { seeded: 0 };
18
+
19
+ const now = new Date().toISOString();
20
+ let order = 0;
21
+ for (const p of siteConfig.work.items) {
22
+ await ctx.db.unsafe.insert("Project", {
23
+ title: p.title,
24
+ slug: p.slug || slugify(p.title),
25
+ client: p.client,
26
+ summary: p.summary,
27
+ year: p.year ?? null,
28
+ tags: p.tags.join(", "),
29
+ selected: p.selected ?? false,
30
+ published: true,
31
+ order: order++,
32
+ challenge: p.challenge ?? null,
33
+ approach: p.approach ?? null,
34
+ outcome: p.outcome ?? null,
35
+ liveUrl: p.liveUrl ?? null,
36
+ createdAt: now,
37
+ });
38
+ }
39
+ return { seeded: order };
40
+ },
41
+ });
@@ -0,0 +1,74 @@
1
+ import { mutation } from "@pylonsync/functions";
2
+ import { emailMatchesOwner } from "../lib/owner";
3
+ import { siteConfig } from "../lib/site.config";
4
+ import { lineItemsTotal, slugify, type ProjectRow } from "../lib/agency";
5
+
6
+ // seedStudioBackoffice — owner-only, idempotent. Fills the dashboard's CRM +
7
+ // billing with the demo clients/invoices from config so it isn't an empty shell
8
+ // on first sign-in. Private data (PII + money) is only ever seeded for the
9
+ // signed-in owner — never by an anonymous visitor — which is why this is
10
+ // owner-gated, unlike the public seedProjects.
11
+ //
12
+ // Invoices link to clients by name and to projects by slug; we resolve those to
13
+ // ids here. Projects may already be seeded (the public site calls seedProjects);
14
+ // if a project isn't present yet, the invoice still carries its title and just
15
+ // doesn't deep-link.
16
+ export default mutation<Record<string, never>, { seeded: boolean }>({
17
+ auth: "user",
18
+ async handler(ctx) {
19
+ const me = await ctx.db.get("User", ctx.auth.userId);
20
+ if (!emailMatchesOwner(me?.email as string | undefined, ctx.env.PYLON_OWNER_EMAIL)) {
21
+ throw ctx.error("POLICY_DENIED", "Only the owner can seed the back-office.");
22
+ }
23
+
24
+ await ctx.db.advisoryLock("agency_seed_backoffice");
25
+ const existingClients = await ctx.db.unsafe.list("Client");
26
+ if (existingClients.length > 0) return { seeded: false };
27
+
28
+ const now = new Date().toISOString();
29
+
30
+ // Clients first — keep a name → id map for the invoices.
31
+ const clientIdByName = new Map<string, string>();
32
+ for (const c of siteConfig.backoffice.clients) {
33
+ const id = await ctx.db.unsafe.insert("Client", {
34
+ name: c.name,
35
+ company: c.company ?? null,
36
+ email: c.email ?? null,
37
+ phone: c.phone ?? null,
38
+ status: c.status ?? "prospect",
39
+ notes: c.notes ?? null,
40
+ createdAt: now,
41
+ });
42
+ clientIdByName.set(c.name, id);
43
+ }
44
+
45
+ // Resolve project links by slug (config slug, or derived from the title).
46
+ const projects = (await ctx.db.unsafe.list("Project")) as unknown as ProjectRow[];
47
+ const projectBySlug = new Map(projects.map((p) => [p.slug, p]));
48
+ const titleBySlug = new Map(
49
+ siteConfig.work.items.map((p) => [p.slug || slugify(p.title), p.title]),
50
+ );
51
+
52
+ for (const inv of siteConfig.backoffice.invoices) {
53
+ const clientId = clientIdByName.get(inv.client);
54
+ if (!clientId) continue; // skip an invoice whose client wasn't seeded
55
+ const project = inv.projectSlug ? projectBySlug.get(inv.projectSlug) : undefined;
56
+ await ctx.db.unsafe.insert("Invoice", {
57
+ number: inv.number,
58
+ clientId,
59
+ clientName: inv.client,
60
+ projectId: project?.id ?? null,
61
+ projectTitle: project?.title ?? (inv.projectSlug ? titleBySlug.get(inv.projectSlug) ?? null : null),
62
+ lineItems: JSON.stringify(inv.lineItems),
63
+ amountCents: lineItemsTotal(inv.lineItems),
64
+ status: inv.status ?? "draft",
65
+ issuedAt: inv.issuedAt ?? null,
66
+ dueAt: inv.dueAt ?? null,
67
+ notes: null,
68
+ createdAt: now,
69
+ });
70
+ }
71
+
72
+ return { seeded: true };
73
+ },
74
+ });
@@ -0,0 +1,32 @@
1
+ import { mutation, v } from "@pylonsync/functions";
2
+ import { emailMatchesOwner } from "../lib/owner";
3
+
4
+ // setCapacity — owner-only. Set the booking window label and how many project
5
+ // slots are open (e.g. at the start of a new quarter). The update syncs to
6
+ // every open landing page, so the hero counter reflects it live. Creates the
7
+ // row if it doesn't exist yet.
8
+ export default mutation<
9
+ { label: string; openSlots: number },
10
+ { ok: boolean; openSlots: number }
11
+ >({
12
+ auth: "user",
13
+ args: { label: v.string(), openSlots: v.int() },
14
+ async handler(ctx, args) {
15
+ const me = await ctx.db.get("User", ctx.auth.userId);
16
+ if (!emailMatchesOwner(me?.email as string | undefined, ctx.env.PYLON_OWNER_EMAIL)) {
17
+ throw ctx.error("POLICY_DENIED", "Only the owner can manage capacity.");
18
+ }
19
+ const label = args.label.trim().slice(0, 60);
20
+ const openSlots = Math.max(0, Math.trunc(args.openSlots));
21
+
22
+ await ctx.db.advisoryLock("agency_capacity");
23
+ const cap = ((await ctx.db.unsafe.list("Capacity")) as unknown as { id: string }[])[0];
24
+ const now = new Date().toISOString();
25
+ if (cap) {
26
+ await ctx.db.unsafe.update("Capacity", cap.id, { label, openSlots, updatedAt: now });
27
+ } else {
28
+ await ctx.db.unsafe.insert("Capacity", { label, openSlots, updatedAt: now });
29
+ }
30
+ return { ok: true, openSlots };
31
+ },
32
+ });
@@ -0,0 +1,27 @@
1
+ import { mutation, v } from "@pylonsync/functions";
2
+ import { emailMatchesOwner } from "../lib/owner";
3
+
4
+ // setInvoiceStatus — owner-only. Move a bill along its lifecycle
5
+ // (draft → sent → paid, or → overdue) without re-opening the full editor. The
6
+ // dashboard's per-invoice status menu calls this.
7
+ const STATUSES = new Set(["draft", "sent", "paid", "overdue"]);
8
+
9
+ export default mutation<{ id: string; status: string }, { ok: boolean }>({
10
+ auth: "user",
11
+ args: { id: v.id("Invoice"), status: v.string() },
12
+ async handler(ctx, args) {
13
+ const me = await ctx.db.get("User", ctx.auth.userId);
14
+ if (!emailMatchesOwner(me?.email as string | undefined, ctx.env.PYLON_OWNER_EMAIL)) {
15
+ throw ctx.error("POLICY_DENIED", "Only the owner can manage invoices.");
16
+ }
17
+ const status = args.status.trim();
18
+ if (!STATUSES.has(status)) {
19
+ throw ctx.error("INVALID_ARGS", "Status must be draft, sent, paid, or overdue.");
20
+ }
21
+ const invoice = await ctx.db.get("Invoice", args.id);
22
+ if (!invoice) throw ctx.error("NOT_FOUND", "Invoice not found.");
23
+
24
+ await ctx.db.unsafe.update("Invoice", args.id, { status });
25
+ return { ok: true };
26
+ },
27
+ });
@@ -0,0 +1,35 @@
1
+ import { mutation, v } from "@pylonsync/functions";
2
+ import { emailMatchesOwner } from "../lib/owner";
3
+
4
+ // setProjectFlags — owner-only. A quick toggle for the two flags the owner flips
5
+ // most: `selected` (feature it on the homepage) and `published` (show it on the
6
+ // public site at all). Separate from the full upsertProject so the dashboard can
7
+ // flip a switch without round-tripping the whole form. Pass only the flag(s) you
8
+ // want to change.
9
+ export default mutation<
10
+ { id: string; selected?: boolean; published?: boolean },
11
+ { ok: boolean }
12
+ >({
13
+ auth: "user",
14
+ args: {
15
+ id: v.id("Project"),
16
+ selected: v.optional(v.boolean()),
17
+ published: v.optional(v.boolean()),
18
+ },
19
+ async handler(ctx, args) {
20
+ const me = await ctx.db.get("User", ctx.auth.userId);
21
+ if (!emailMatchesOwner(me?.email as string | undefined, ctx.env.PYLON_OWNER_EMAIL)) {
22
+ throw ctx.error("POLICY_DENIED", "Only the owner can manage projects.");
23
+ }
24
+ const project = await ctx.db.get("Project", args.id);
25
+ if (!project) throw ctx.error("NOT_FOUND", "Project not found.");
26
+
27
+ const patch: Record<string, boolean> = {};
28
+ if (typeof args.selected === "boolean") patch.selected = args.selected;
29
+ if (typeof args.published === "boolean") patch.published = args.published;
30
+ if (Object.keys(patch).length > 0) {
31
+ await ctx.db.unsafe.update("Project", args.id, patch);
32
+ }
33
+ return { ok: true };
34
+ },
35
+ });
@@ -0,0 +1,55 @@
1
+ import { mutation, v } from "@pylonsync/functions";
2
+
3
+ const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
4
+
5
+ // submitInquiry — a prospect's "start a project" lead. A `mutation` (it writes
6
+ // the Inquiry row transactionally). `auth: "public"` — a prospect has no
7
+ // account.
8
+ //
9
+ // PRIVACY: it returns only `{ ok }`, never an Inquiry row or anyone's details.
10
+ // Submitting does NOT consume a capacity slot — a lead isn't a booking. The
11
+ // owner books it from the dashboard, which is what decrements the live counter.
12
+ export default mutation<
13
+ {
14
+ name: string;
15
+ email: string;
16
+ company?: string;
17
+ projectType?: string;
18
+ budget?: string;
19
+ message?: string;
20
+ },
21
+ { ok: boolean }
22
+ >({
23
+ auth: "public",
24
+ args: {
25
+ name: v.string(),
26
+ email: v.string(),
27
+ company: v.optional(v.string()),
28
+ projectType: v.optional(v.string()),
29
+ budget: v.optional(v.string()),
30
+ message: v.optional(v.string()),
31
+ },
32
+ async handler(ctx, args) {
33
+ const name = args.name.trim();
34
+ const email = args.email.trim().toLowerCase();
35
+ if (name.length < 1 || name.length > 120) {
36
+ throw ctx.error("INVALID_ARGS", "Please enter your name.");
37
+ }
38
+ if (!EMAIL_RE.test(email) || email.length > 254) {
39
+ throw ctx.error("INVALID_ARGS", "Please enter a valid email address.");
40
+ }
41
+ const clip = (s: string | undefined, max: number) => (s ? s.trim().slice(0, max) : null);
42
+
43
+ await ctx.db.unsafe.insert("Inquiry", {
44
+ name,
45
+ email,
46
+ company: clip(args.company, 160),
47
+ projectType: clip(args.projectType, 80),
48
+ budget: clip(args.budget, 80),
49
+ message: clip(args.message, 4000),
50
+ status: "new",
51
+ createdAt: new Date().toISOString(),
52
+ });
53
+ return { ok: true };
54
+ },
55
+ });
@@ -0,0 +1,73 @@
1
+ import { mutation, v } from "@pylonsync/functions";
2
+ import { emailMatchesOwner } from "../lib/owner";
3
+ import type { ClientRow } from "../lib/agency";
4
+
5
+ // upsertClient — owner-only. Create a CRM contact or edit one (pass `id`). When
6
+ // a client's name or company changes, any invoice that referenced it keeps its
7
+ // denormalized `clientName` from when it was issued — invoices are a financial
8
+ // record and shouldn't silently rewrite themselves; the link by `clientId`
9
+ // still resolves for navigation.
10
+ type Args = {
11
+ id?: string;
12
+ name: string;
13
+ company?: string;
14
+ email?: string;
15
+ phone?: string;
16
+ status?: string;
17
+ notes?: string;
18
+ };
19
+
20
+ const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
21
+ const STATUSES = new Set(["prospect", "active", "past"]);
22
+ const clip = (s: string | undefined, max: number): string | null =>
23
+ s != null && s.trim().length > 0 ? s.trim().slice(0, max) : null;
24
+
25
+ export default mutation<Args, { ok: boolean; id: string }>({
26
+ auth: "user",
27
+ args: {
28
+ id: v.optional(v.string()),
29
+ name: v.string(),
30
+ company: v.optional(v.string()),
31
+ email: v.optional(v.string()),
32
+ phone: v.optional(v.string()),
33
+ status: v.optional(v.string()),
34
+ notes: v.optional(v.string()),
35
+ },
36
+ async handler(ctx, args) {
37
+ const me = await ctx.db.get("User", ctx.auth.userId);
38
+ if (!emailMatchesOwner(me?.email as string | undefined, ctx.env.PYLON_OWNER_EMAIL)) {
39
+ throw ctx.error("POLICY_DENIED", "Only the owner can manage clients.");
40
+ }
41
+ const name = args.name.trim();
42
+ if (name.length < 1 || name.length > 120) {
43
+ throw ctx.error("INVALID_ARGS", "A client name is required (up to 120 chars).");
44
+ }
45
+ const email = clip(args.email, 254);
46
+ if (email && !EMAIL_RE.test(email)) {
47
+ throw ctx.error("INVALID_ARGS", "Enter a valid email address.");
48
+ }
49
+ const status = STATUSES.has((args.status ?? "").trim()) ? args.status!.trim() : "prospect";
50
+
51
+ const patch = {
52
+ name,
53
+ company: clip(args.company, 160),
54
+ email,
55
+ phone: clip(args.phone, 40),
56
+ status,
57
+ notes: clip(args.notes, 2000),
58
+ };
59
+
60
+ if (args.id) {
61
+ const existing = (await ctx.db.get("Client", args.id)) as ClientRow | null;
62
+ if (!existing) throw ctx.error("NOT_FOUND", "Client not found.");
63
+ await ctx.db.unsafe.update("Client", args.id, patch);
64
+ return { ok: true, id: args.id };
65
+ }
66
+
67
+ const id = await ctx.db.unsafe.insert("Client", {
68
+ ...patch,
69
+ createdAt: new Date().toISOString(),
70
+ });
71
+ return { ok: true, id };
72
+ },
73
+ });