create-crm-starter 0.1.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 (261) hide show
  1. package/README.md +53 -0
  2. package/dist/index.js +2341 -0
  3. package/package.json +51 -0
  4. package/template/base/_dot_gitignore +11 -0
  5. package/template/base/eslint.config.mjs +7 -0
  6. package/template/base/next.config.ts +17 -0
  7. package/template/base/package.json +35 -0
  8. package/template/base/postcss.config.mjs +7 -0
  9. package/template/base/scripts/setup.mjs +146 -0
  10. package/template/base/src/app/globals.css +68 -0
  11. package/template/base/src/app/layout.tsx +19 -0
  12. package/template/base/src/app/page.tsx +37 -0
  13. package/template/base/src/components/ui/badge.tsx +29 -0
  14. package/template/base/src/components/ui/button.tsx +49 -0
  15. package/template/base/src/components/ui/card.tsx +54 -0
  16. package/template/base/src/components/ui/input.tsx +19 -0
  17. package/template/base/src/components/ui/label.tsx +19 -0
  18. package/template/base/src/components/ui/separator.tsx +25 -0
  19. package/template/base/src/components/ui/skeleton.tsx +7 -0
  20. package/template/base/src/lib/utils.ts +17 -0
  21. package/template/base/tsconfig.json +21 -0
  22. package/template/extras/auth-better-auth/src/app/(auth)/layout.tsx +7 -0
  23. package/template/extras/auth-better-auth/src/app/(auth)/sign-in/page.tsx +65 -0
  24. package/template/extras/auth-better-auth/src/app/(auth)/sign-up/page.tsx +70 -0
  25. package/template/extras/auth-better-auth/src/app/(dashboard)/admin/page.tsx +68 -0
  26. package/template/extras/auth-better-auth/src/app/(dashboard)/dashboard/page.tsx +42 -0
  27. package/template/extras/auth-better-auth/src/app/(dashboard)/layout.tsx +41 -0
  28. package/template/extras/auth-better-auth/src/app/api/auth/[...all]/route.ts +4 -0
  29. package/template/extras/auth-better-auth/src/components/sign-out-button.tsx +19 -0
  30. package/template/extras/auth-better-auth/src/lib/auth-client.ts +7 -0
  31. package/template/extras/auth-better-auth/src/lib/auth-server.ts +51 -0
  32. package/template/extras/auth-better-auth/src/lib/auth.ts +76 -0
  33. package/template/extras/auth-better-auth/src/middleware.ts +25 -0
  34. package/template/extras/auth-clerk/src/app/(auth)/layout.tsx +7 -0
  35. package/template/extras/auth-clerk/src/app/(auth)/sign-in/[[...sign-in]]/page.tsx +5 -0
  36. package/template/extras/auth-clerk/src/app/(auth)/sign-up/[[...sign-up]]/page.tsx +5 -0
  37. package/template/extras/auth-clerk/src/app/(dashboard)/admin/page.tsx +37 -0
  38. package/template/extras/auth-clerk/src/app/(dashboard)/dashboard/page.tsx +42 -0
  39. package/template/extras/auth-clerk/src/app/(dashboard)/layout.tsx +57 -0
  40. package/template/extras/auth-clerk/src/lib/auth.ts +55 -0
  41. package/template/extras/auth-clerk/src/middleware.ts +17 -0
  42. package/template/extras/calendar-dispatch/_shared/src/app/(dashboard)/calendar/page.tsx +39 -0
  43. package/template/extras/calendar-dispatch/drizzle/src/app/(dashboard)/calendar/page.tsx +21 -0
  44. package/template/extras/calendar-dispatch/drizzle/src/components/calendar/calendar-board.tsx +195 -0
  45. package/template/extras/calendar-dispatch/drizzle/src/lib/calendar/actions.ts +35 -0
  46. package/template/extras/calendar-dispatch/drizzle/src/lib/calendar/data.ts +74 -0
  47. package/template/extras/checklists/_shared/src/app/(dashboard)/checklists/[id]/page.tsx +48 -0
  48. package/template/extras/checklists/_shared/src/app/(dashboard)/checklists/new/page.tsx +15 -0
  49. package/template/extras/checklists/_shared/src/app/(dashboard)/checklists/page.tsx +83 -0
  50. package/template/extras/checklists/_shared/src/components/jobs/job-checklists-section.tsx +18 -0
  51. package/template/extras/checklists/_shared/src/lib/checklists/data.ts +17 -0
  52. package/template/extras/checklists/_shared/src/lib/checklists/sample-data.ts +56 -0
  53. package/template/extras/checklists/_shared/src/lib/checklists/types.ts +47 -0
  54. package/template/extras/checklists/drizzle/src/app/(dashboard)/checklists/new/page.tsx +10 -0
  55. package/template/extras/checklists/drizzle/src/components/checklists/new-template-form.tsx +158 -0
  56. package/template/extras/checklists/drizzle/src/components/jobs/job-checklists-client.tsx +202 -0
  57. package/template/extras/checklists/drizzle/src/components/jobs/job-checklists-section.tsx +24 -0
  58. package/template/extras/checklists/drizzle/src/db/schema/checklists.ts +52 -0
  59. package/template/extras/checklists/drizzle/src/lib/checklists/actions.ts +112 -0
  60. package/template/extras/checklists/drizzle/src/lib/checklists/data.ts +80 -0
  61. package/template/extras/comms-email/src/app/(dashboard)/email/page.tsx +32 -0
  62. package/template/extras/comms-sms/_shared/src/app/(dashboard)/sms/new/page.tsx +35 -0
  63. package/template/extras/comms-sms/_shared/src/app/(dashboard)/sms/page.tsx +55 -0
  64. package/template/extras/comms-sms/drizzle/src/app/(dashboard)/sms/[customerId]/page.tsx +102 -0
  65. package/template/extras/comms-sms/drizzle/src/app/(dashboard)/sms/new/page.tsx +120 -0
  66. package/template/extras/comms-sms/drizzle/src/app/(dashboard)/sms/page.tsx +70 -0
  67. package/template/extras/comms-sms/drizzle/src/app/api/twilio/sms/route.ts +69 -0
  68. package/template/extras/comms-sms/drizzle/src/db/schema/sms-messages.ts +27 -0
  69. package/template/extras/comms-sms/drizzle/src/lib/sms/actions.ts +107 -0
  70. package/template/extras/comms-sms/drizzle/src/lib/sms/data.ts +111 -0
  71. package/template/extras/customer-portal/_shared/src/app/portal/[token]/layout.tsx +51 -0
  72. package/template/extras/customer-portal/drizzle/src/app/portal/[token]/appointments/page.tsx +78 -0
  73. package/template/extras/customer-portal/drizzle/src/app/portal/[token]/estimates/page.tsx +94 -0
  74. package/template/extras/customer-portal/drizzle/src/app/portal/[token]/invoices/page.tsx +71 -0
  75. package/template/extras/customer-portal/drizzle/src/app/portal/[token]/page.tsx +118 -0
  76. package/template/extras/customer-portal/drizzle/src/components/portal/estimate-approval.tsx +141 -0
  77. package/template/extras/customer-portal/drizzle/src/components/portal/signature-pad.tsx +126 -0
  78. package/template/extras/customer-portal/drizzle/src/lib/portal/actions.ts +107 -0
  79. package/template/extras/customer-portal/drizzle/src/lib/portal/data.ts +158 -0
  80. package/template/extras/customers/_fragments/convex.txt +28 -0
  81. package/template/extras/customers/_shared/src/app/(dashboard)/customers/[id]/page.tsx +81 -0
  82. package/template/extras/customers/_shared/src/app/(dashboard)/customers/new/page.tsx +16 -0
  83. package/template/extras/customers/_shared/src/app/(dashboard)/customers/page.tsx +73 -0
  84. package/template/extras/customers/_shared/src/lib/customers/data.ts +15 -0
  85. package/template/extras/customers/_shared/src/lib/customers/sample-data.ts +67 -0
  86. package/template/extras/customers/_shared/src/lib/customers/types.ts +31 -0
  87. package/template/extras/customers/convex/convex/customers.ts +52 -0
  88. package/template/extras/customers/convex/src/lib/customers/data.ts +64 -0
  89. package/template/extras/customers/drizzle/src/app/(dashboard)/customers/new/page.tsx +82 -0
  90. package/template/extras/customers/drizzle/src/db/schema/customers.ts +34 -0
  91. package/template/extras/customers/drizzle/src/lib/customers/actions.ts +67 -0
  92. package/template/extras/customers/drizzle/src/lib/customers/data.ts +34 -0
  93. package/template/extras/db-convex/convex/_generated/README.md +13 -0
  94. package/template/extras/db-convex/convex/_generated/api.d.ts +8 -0
  95. package/template/extras/db-convex/convex/_generated/api.js +12 -0
  96. package/template/extras/db-convex/convex/_generated/dataModel.d.ts +9 -0
  97. package/template/extras/db-convex/convex/_generated/server.d.ts +18 -0
  98. package/template/extras/db-convex/convex/_generated/server.js +12 -0
  99. package/template/extras/db-convex/convex/auth.config.ts +17 -0
  100. package/template/extras/db-convex/convex/schema.ts +28 -0
  101. package/template/extras/db-convex/src/app/layout.tsx +20 -0
  102. package/template/extras/db-convex/src/components/providers.tsx +28 -0
  103. package/template/extras/db-convex/src/lib/convex.ts +6 -0
  104. package/template/extras/db-drizzle-pg/docker-compose.yml +21 -0
  105. package/template/extras/db-drizzle-pg/drizzle.config.ts +24 -0
  106. package/template/extras/db-drizzle-pg/src/app/layout.tsx +20 -0
  107. package/template/extras/db-drizzle-pg/src/components/providers.tsx +16 -0
  108. package/template/extras/db-drizzle-pg/src/db/client.ts +14 -0
  109. package/template/extras/db-drizzle-pg/src/db/schema/auth.ts +62 -0
  110. package/template/extras/db-drizzle-pg/src/db/schema/index.ts +3 -0
  111. package/template/extras/emergency-dispatch/src/app/(dashboard)/emergency/page.tsx +43 -0
  112. package/template/extras/equipment-tracking/src/app/(dashboard)/equipment/page.tsx +61 -0
  113. package/template/extras/estimates-invoices/_shared/src/app/(dashboard)/estimates/[id]/page.tsx +123 -0
  114. package/template/extras/estimates-invoices/_shared/src/app/(dashboard)/estimates/new/page.tsx +22 -0
  115. package/template/extras/estimates-invoices/_shared/src/app/(dashboard)/estimates/page.tsx +102 -0
  116. package/template/extras/estimates-invoices/_shared/src/app/(dashboard)/invoices/[id]/page.tsx +168 -0
  117. package/template/extras/estimates-invoices/_shared/src/app/(dashboard)/invoices/page.tsx +100 -0
  118. package/template/extras/estimates-invoices/_shared/src/components/estimates/convert-to-job-button.tsx +14 -0
  119. package/template/extras/estimates-invoices/_shared/src/components/invoices/pay-invoice-button.tsx +15 -0
  120. package/template/extras/estimates-invoices/_shared/src/components/invoices/send-invoice-email-button.tsx +14 -0
  121. package/template/extras/estimates-invoices/_shared/src/lib/estimates/data.ts +14 -0
  122. package/template/extras/estimates-invoices/_shared/src/lib/estimates/sample-data.ts +74 -0
  123. package/template/extras/estimates-invoices/_shared/src/lib/estimates/types.ts +60 -0
  124. package/template/extras/estimates-invoices/_shared/src/lib/invoices/data.ts +14 -0
  125. package/template/extras/estimates-invoices/_shared/src/lib/invoices/sample-data.ts +83 -0
  126. package/template/extras/estimates-invoices/_shared/src/lib/invoices/types.ts +78 -0
  127. package/template/extras/estimates-invoices/drizzle/src/app/(dashboard)/estimates/new/page.tsx +18 -0
  128. package/template/extras/estimates-invoices/drizzle/src/app/api/stripe/webhook/route.ts +87 -0
  129. package/template/extras/estimates-invoices/drizzle/src/app/i/[token]/page.tsx +148 -0
  130. package/template/extras/estimates-invoices/drizzle/src/components/estimates/convert-to-job-button.tsx +18 -0
  131. package/template/extras/estimates-invoices/drizzle/src/components/estimates/new-estimate-form.tsx +261 -0
  132. package/template/extras/estimates-invoices/drizzle/src/components/invoices/pay-invoice-button.tsx +19 -0
  133. package/template/extras/estimates-invoices/drizzle/src/components/invoices/public-pay-button.tsx +20 -0
  134. package/template/extras/estimates-invoices/drizzle/src/components/invoices/send-invoice-email-button.tsx +37 -0
  135. package/template/extras/estimates-invoices/drizzle/src/components/jobs/generate-invoice-button.tsx +23 -0
  136. package/template/extras/estimates-invoices/drizzle/src/db/schema/estimates.ts +41 -0
  137. package/template/extras/estimates-invoices/drizzle/src/db/schema/invoices.ts +59 -0
  138. package/template/extras/estimates-invoices/drizzle/src/lib/estimates/actions.ts +110 -0
  139. package/template/extras/estimates-invoices/drizzle/src/lib/estimates/data.ts +57 -0
  140. package/template/extras/estimates-invoices/drizzle/src/lib/invoices/actions.ts +199 -0
  141. package/template/extras/estimates-invoices/drizzle/src/lib/invoices/data.ts +99 -0
  142. package/template/extras/estimates-invoices/drizzle/src/lib/invoices/email-actions.ts +102 -0
  143. package/template/extras/inspection-checklists/src/app/(dashboard)/inspections/page.tsx +60 -0
  144. package/template/extras/jobs/_fragments/convex.txt +21 -0
  145. package/template/extras/jobs/_shared/src/app/(dashboard)/jobs/[id]/page.tsx +102 -0
  146. package/template/extras/jobs/_shared/src/app/(dashboard)/jobs/page.tsx +72 -0
  147. package/template/extras/jobs/_shared/src/components/jobs/advance-status-button.tsx +21 -0
  148. package/template/extras/jobs/_shared/src/components/jobs/generate-invoice-button.tsx +15 -0
  149. package/template/extras/jobs/_shared/src/components/jobs/photo-gallery.tsx +17 -0
  150. package/template/extras/jobs/_shared/src/components/jobs/photos-section.tsx +18 -0
  151. package/template/extras/jobs/_shared/src/lib/jobs/data.ts +14 -0
  152. package/template/extras/jobs/_shared/src/lib/jobs/sample-data.ts +50 -0
  153. package/template/extras/jobs/_shared/src/lib/jobs/types.ts +62 -0
  154. package/template/extras/jobs/convex/convex/jobs.ts +46 -0
  155. package/template/extras/jobs/convex/src/lib/jobs/data.ts +65 -0
  156. package/template/extras/jobs/drizzle/src/app/(dashboard)/jobs/new/page.tsx +18 -0
  157. package/template/extras/jobs/drizzle/src/components/jobs/advance-status-button.tsx +34 -0
  158. package/template/extras/jobs/drizzle/src/components/jobs/new-job-form.tsx +275 -0
  159. package/template/extras/jobs/drizzle/src/components/jobs/photo-gallery.tsx +130 -0
  160. package/template/extras/jobs/drizzle/src/components/jobs/photos-section.tsx +7 -0
  161. package/template/extras/jobs/drizzle/src/db/schema/job-attachments.ts +26 -0
  162. package/template/extras/jobs/drizzle/src/db/schema/jobs.ts +29 -0
  163. package/template/extras/jobs/drizzle/src/lib/jobs/actions.ts +71 -0
  164. package/template/extras/jobs/drizzle/src/lib/jobs/data.ts +48 -0
  165. package/template/extras/jobs/drizzle/src/lib/jobs/photos-actions.ts +121 -0
  166. package/template/extras/jobs/drizzle/src/lib/r2.ts +45 -0
  167. package/template/extras/landing-page/_shared/src/app/book/page.tsx +43 -0
  168. package/template/extras/landing-page/_shared/src/app/book/thanks/page.tsx +31 -0
  169. package/template/extras/landing-page/_shared/src/app/page.tsx +81 -0
  170. package/template/extras/landing-page/drizzle/src/app/book/page.tsx +105 -0
  171. package/template/extras/landing-page/drizzle/src/lib/booking/actions.ts +97 -0
  172. package/template/extras/maintenance-plans/src/app/(dashboard)/maintenance-plans/page.tsx +72 -0
  173. package/template/extras/mobile/mobile/README.md +67 -0
  174. package/template/extras/mobile/mobile/_dot_env.example +5 -0
  175. package/template/extras/mobile/mobile/_dot_gitignore +26 -0
  176. package/template/extras/mobile/mobile/app/(app)/_layout.tsx +37 -0
  177. package/template/extras/mobile/mobile/app/(app)/estimate.tsx +135 -0
  178. package/template/extras/mobile/mobile/app/(app)/inbox/[customerId].tsx +103 -0
  179. package/template/extras/mobile/mobile/app/(app)/inbox/index.tsx +70 -0
  180. package/template/extras/mobile/mobile/app/(app)/index.tsx +111 -0
  181. package/template/extras/mobile/mobile/app/(app)/job/[id]/checklist.tsx +99 -0
  182. package/template/extras/mobile/mobile/app/(app)/job/[id]/invoice.tsx +143 -0
  183. package/template/extras/mobile/mobile/app/(app)/job/[id].tsx +259 -0
  184. package/template/extras/mobile/mobile/app/_layout.tsx +14 -0
  185. package/template/extras/mobile/mobile/app/index.tsx +23 -0
  186. package/template/extras/mobile/mobile/app/sign-in.tsx +101 -0
  187. package/template/extras/mobile/mobile/app.brand.ts +14 -0
  188. package/template/extras/mobile/mobile/app.config.ts +40 -0
  189. package/template/extras/mobile/mobile/app.features.ts +11 -0
  190. package/template/extras/mobile/mobile/components/SignaturePad.tsx +60 -0
  191. package/template/extras/mobile/mobile/eas.json +22 -0
  192. package/template/extras/mobile/mobile/lib/api.ts +253 -0
  193. package/template/extras/mobile/mobile/lib/auth.ts +51 -0
  194. package/template/extras/mobile/mobile/lib/format.ts +23 -0
  195. package/template/extras/mobile/mobile/lib/push.ts +24 -0
  196. package/template/extras/mobile/mobile/lib/theme.ts +16 -0
  197. package/template/extras/mobile/mobile/package.json +34 -0
  198. package/template/extras/mobile/mobile/tsconfig.json +11 -0
  199. package/template/extras/mobile-api/drizzle/src/app/api/mobile/v1/customers/[id]/route.ts +18 -0
  200. package/template/extras/mobile-api/drizzle/src/app/api/mobile/v1/devices/route.ts +40 -0
  201. package/template/extras/mobile-api/drizzle/src/app/api/mobile/v1/jobs/[id]/attachments/route.ts +59 -0
  202. package/template/extras/mobile-api/drizzle/src/app/api/mobile/v1/jobs/[id]/checklists/item/route.ts +34 -0
  203. package/template/extras/mobile-api/drizzle/src/app/api/mobile/v1/jobs/[id]/checklists/route.ts +15 -0
  204. package/template/extras/mobile-api/drizzle/src/app/api/mobile/v1/jobs/[id]/route.ts +35 -0
  205. package/template/extras/mobile-api/drizzle/src/app/api/mobile/v1/jobs/[id]/status/route.ts +28 -0
  206. package/template/extras/mobile-api/drizzle/src/app/api/mobile/v1/jobs/[id]/time/clock-in/route.ts +35 -0
  207. package/template/extras/mobile-api/drizzle/src/app/api/mobile/v1/jobs/[id]/time/clock-out/route.ts +27 -0
  208. package/template/extras/mobile-api/drizzle/src/app/api/mobile/v1/jobs/[id]/time/route.ts +36 -0
  209. package/template/extras/mobile-api/drizzle/src/app/api/mobile/v1/jobs/route.ts +30 -0
  210. package/template/extras/mobile-api/drizzle/src/app/api/mobile/v1/me/route.ts +26 -0
  211. package/template/extras/mobile-api/drizzle/src/app/api/mobile/v1/uploads/sign/route.ts +46 -0
  212. package/template/extras/mobile-api/drizzle/src/db/schema/push-tokens.ts +21 -0
  213. package/template/extras/mobile-api/drizzle/src/db/schema/time-entries.ts +23 -0
  214. package/template/extras/mobile-api/drizzle/src/lib/mobile/cors.ts +30 -0
  215. package/template/extras/mobile-api/drizzle/src/lib/mobile/guard.ts +49 -0
  216. package/template/extras/mobile-api/drizzle/src/lib/push/send.ts +56 -0
  217. package/template/extras/mobile-api/estimates/src/app/api/mobile/v1/estimates/route.ts +52 -0
  218. package/template/extras/mobile-api/estimates/src/app/api/mobile/v1/price-book/route.ts +14 -0
  219. package/template/extras/mobile-api/invoices/src/app/api/mobile/v1/invoices/[id]/payments/route.ts +32 -0
  220. package/template/extras/mobile-api/invoices/src/app/api/mobile/v1/invoices/[id]/route.ts +28 -0
  221. package/template/extras/mobile-api/invoices/src/app/api/mobile/v1/jobs/[id]/invoice/route.ts +82 -0
  222. package/template/extras/mobile-api/sms/src/app/api/mobile/v1/sms/send/route.ts +32 -0
  223. package/template/extras/mobile-api/sms/src/app/api/mobile/v1/sms/threads/[customerId]/route.ts +15 -0
  224. package/template/extras/mobile-api/sms/src/app/api/mobile/v1/sms/threads/route.ts +14 -0
  225. package/template/extras/payments-stripe/src/app/(dashboard)/payments/page.tsx +56 -0
  226. package/template/extras/payments-stripe/src/app/api/stripe/webhook/route.ts +56 -0
  227. package/template/extras/payments-stripe/src/components/payments/demo-checkout-button.tsx +18 -0
  228. package/template/extras/payments-stripe/src/lib/payments/actions.ts +63 -0
  229. package/template/extras/payments-stripe/src/lib/stripe.ts +25 -0
  230. package/template/extras/permit-tracking/src/app/(dashboard)/permits/page.tsx +56 -0
  231. package/template/extras/price-book/_shared/src/app/(dashboard)/price-book/new/page.tsx +20 -0
  232. package/template/extras/price-book/_shared/src/app/(dashboard)/price-book/page.tsx +79 -0
  233. package/template/extras/price-book/_shared/src/components/price-book/item-picker.tsx +145 -0
  234. package/template/extras/price-book/_shared/src/lib/price-book/actions.ts +14 -0
  235. package/template/extras/price-book/_shared/src/lib/price-book/data.ts +28 -0
  236. package/template/extras/price-book/_shared/src/lib/price-book/sample-data.ts +62 -0
  237. package/template/extras/price-book/_shared/src/lib/price-book/types.ts +35 -0
  238. package/template/extras/price-book/drizzle/src/app/(dashboard)/price-book/new/page.tsx +18 -0
  239. package/template/extras/price-book/drizzle/src/components/price-book/new-item-form.tsx +254 -0
  240. package/template/extras/price-book/drizzle/src/db/schema/price-book.ts +33 -0
  241. package/template/extras/price-book/drizzle/src/lib/price-book/actions.ts +72 -0
  242. package/template/extras/price-book/drizzle/src/lib/price-book/data.ts +81 -0
  243. package/template/extras/reporting/src/app/(dashboard)/reports/page.tsx +66 -0
  244. package/template/extras/reviews/src/app/(dashboard)/reviews/page.tsx +58 -0
  245. package/template/extras/seed/_shared/scripts/seed.ts +35 -0
  246. package/template/extras/seed/drizzle/scripts/seed.ts +314 -0
  247. package/template/extras/service-plans/_shared/src/app/(dashboard)/service-plans/[id]/page.tsx +114 -0
  248. package/template/extras/service-plans/_shared/src/app/(dashboard)/service-plans/new/page.tsx +18 -0
  249. package/template/extras/service-plans/_shared/src/app/(dashboard)/service-plans/page.tsx +92 -0
  250. package/template/extras/service-plans/_shared/src/components/service-plans/subscribe-customer-section.tsx +17 -0
  251. package/template/extras/service-plans/_shared/src/lib/service-plans/data.ts +14 -0
  252. package/template/extras/service-plans/_shared/src/lib/service-plans/sample-data.ts +83 -0
  253. package/template/extras/service-plans/_shared/src/lib/service-plans/types.ts +57 -0
  254. package/template/extras/service-plans/drizzle/src/app/(dashboard)/service-plans/new/page.tsx +10 -0
  255. package/template/extras/service-plans/drizzle/src/app/api/stripe/webhook/route.ts +143 -0
  256. package/template/extras/service-plans/drizzle/src/components/service-plans/new-plan-form.tsx +126 -0
  257. package/template/extras/service-plans/drizzle/src/components/service-plans/subscribe-customer-form.tsx +88 -0
  258. package/template/extras/service-plans/drizzle/src/components/service-plans/subscribe-customer-section.tsx +12 -0
  259. package/template/extras/service-plans/drizzle/src/db/schema/service-plans.ts +46 -0
  260. package/template/extras/service-plans/drizzle/src/lib/service-plans/actions.ts +196 -0
  261. package/template/extras/service-plans/drizzle/src/lib/service-plans/data.ts +124 -0
@@ -0,0 +1,35 @@
1
+ import type { NextRequest } from 'next/server';
2
+ import { and, eq, isNull } from 'drizzle-orm';
3
+ import { db } from '@/db/client';
4
+ import { timeEntries } from '@/db/schema';
5
+ import { json, preflight } from '@/lib/mobile/cors';
6
+ import { requireApiUser } from '@/lib/mobile/guard';
7
+
8
+ export const dynamic = 'force-dynamic';
9
+ export const OPTIONS = preflight;
10
+
11
+ /**
12
+ * POST /api/mobile/v1/jobs/:id/time/clock-in
13
+ * Opens a time entry. If one is already open on this job for this user it's
14
+ * returned as-is (idempotent — no double clock-in).
15
+ */
16
+ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
17
+ const user = await requireApiUser(req, ['technician', 'dispatcher']);
18
+ if (user instanceof Response) return user;
19
+ const { id } = await params;
20
+
21
+ const [open] = await db
22
+ .select({ id: timeEntries.id })
23
+ .from(timeEntries)
24
+ .where(
25
+ and(eq(timeEntries.jobId, id), eq(timeEntries.userId, user.id), isNull(timeEntries.clockOut)),
26
+ )
27
+ .limit(1);
28
+ if (open) return json({ entryId: open.id, alreadyOpen: true });
29
+
30
+ const [row] = await db
31
+ .insert(timeEntries)
32
+ .values({ jobId: id, userId: user.id })
33
+ .returning({ id: timeEntries.id });
34
+ return json({ entryId: row.id });
35
+ }
@@ -0,0 +1,27 @@
1
+ import type { NextRequest } from 'next/server';
2
+ import { and, eq, isNull } from 'drizzle-orm';
3
+ import { db } from '@/db/client';
4
+ import { timeEntries } from '@/db/schema';
5
+ import { json, preflight } from '@/lib/mobile/cors';
6
+ import { requireApiUser } from '@/lib/mobile/guard';
7
+
8
+ export const dynamic = 'force-dynamic';
9
+ export const OPTIONS = preflight;
10
+
11
+ /**
12
+ * POST /api/mobile/v1/jobs/:id/time/clock-out
13
+ * Closes this tech's open time entry on the job.
14
+ */
15
+ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
16
+ const user = await requireApiUser(req, ['technician', 'dispatcher']);
17
+ if (user instanceof Response) return user;
18
+ const { id } = await params;
19
+
20
+ await db
21
+ .update(timeEntries)
22
+ .set({ clockOut: new Date() })
23
+ .where(
24
+ and(eq(timeEntries.jobId, id), eq(timeEntries.userId, user.id), isNull(timeEntries.clockOut)),
25
+ );
26
+ return json({ ok: true });
27
+ }
@@ -0,0 +1,36 @@
1
+ import type { NextRequest } from 'next/server';
2
+ import { and, desc, eq } from 'drizzle-orm';
3
+ import { db } from '@/db/client';
4
+ import { timeEntries } from '@/db/schema';
5
+ import { json, preflight } from '@/lib/mobile/cors';
6
+ import { requireApiUser } from '@/lib/mobile/guard';
7
+
8
+ export const dynamic = 'force-dynamic';
9
+ export const OPTIONS = preflight;
10
+
11
+ /**
12
+ * GET /api/mobile/v1/jobs/:id/time
13
+ * Returns this tech's time entries on the job + the id of any open entry
14
+ * (clocked in, not yet out) so the app can show a running timer.
15
+ */
16
+ export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
17
+ const user = await requireApiUser(req, ['technician', 'dispatcher']);
18
+ if (user instanceof Response) return user;
19
+ const { id } = await params;
20
+
21
+ const rows = await db
22
+ .select()
23
+ .from(timeEntries)
24
+ .where(and(eq(timeEntries.jobId, id), eq(timeEntries.userId, user.id)))
25
+ .orderBy(desc(timeEntries.clockIn));
26
+
27
+ const open = rows.find((r) => r.clockOut === null);
28
+ return json({
29
+ entries: rows.map((r) => ({
30
+ id: r.id,
31
+ clockIn: r.clockIn.toISOString(),
32
+ clockOut: r.clockOut ? r.clockOut.toISOString() : null,
33
+ })),
34
+ openEntryId: open?.id ?? null,
35
+ });
36
+ }
@@ -0,0 +1,30 @@
1
+ import type { NextRequest } from 'next/server';
2
+ import { getJobs } from '@/lib/jobs/data';
3
+ import { json, preflight } from '@/lib/mobile/cors';
4
+ import { requireApiUser } from '@/lib/mobile/guard';
5
+
6
+ export const dynamic = 'force-dynamic';
7
+ export const OPTIONS = preflight;
8
+
9
+ /**
10
+ * GET /api/mobile/v1/jobs?scope=today|mine|all
11
+ * Technicians get only the jobs assigned to them; dispatchers/admins get
12
+ * all. `scope=today` further limits to jobs scheduled for the current day.
13
+ */
14
+ export async function GET(req: NextRequest) {
15
+ const user = await requireApiUser(req, ['technician', 'dispatcher']);
16
+ if (user instanceof Response) return user;
17
+
18
+ const scope = req.nextUrl.searchParams.get('scope') ?? 'mine';
19
+ const all = await getJobs();
20
+
21
+ // Field techs only ever see their own work; office roles see everything.
22
+ let jobs = user.role === 'technician' ? all.filter((j) => j.assigneeIds.includes(user.id)) : all;
23
+
24
+ if (scope === 'today') {
25
+ const today = new Date().toISOString().slice(0, 10);
26
+ jobs = jobs.filter((j) => j.scheduledAt?.slice(0, 10) === today);
27
+ }
28
+
29
+ return json({ jobs });
30
+ }
@@ -0,0 +1,26 @@
1
+ import type { NextRequest } from 'next/server';
2
+ import { business } from '@/lib/business';
3
+ import { json, preflight } from '@/lib/mobile/cors';
4
+ import { requireApiUser } from '@/lib/mobile/guard';
5
+
6
+ export const dynamic = 'force-dynamic';
7
+ export const OPTIONS = preflight;
8
+
9
+ /**
10
+ * GET /api/mobile/v1/me
11
+ * Returns the signed-in user + the business branding the app uses to theme
12
+ * itself and confirm which company this build points at.
13
+ */
14
+ export async function GET(req: NextRequest) {
15
+ const user = await requireApiUser(req);
16
+ if (user instanceof Response) return user;
17
+
18
+ return json({
19
+ user: { id: user.id, email: user.email, name: user.name, role: user.role },
20
+ business: {
21
+ name: business.name,
22
+ phone: business.phone,
23
+ brand: business.brand,
24
+ },
25
+ });
26
+ }
@@ -0,0 +1,46 @@
1
+ import crypto from 'node:crypto';
2
+ import type { NextRequest } from 'next/server';
3
+ import { PutObjectCommand } from '@aws-sdk/client-s3';
4
+ import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
5
+ import { isR2Configured, publicUrlFor, r2BucketName, r2Client } from '@/lib/r2';
6
+ import { json, jsonError, preflight } from '@/lib/mobile/cors';
7
+ import { requireApiUser } from '@/lib/mobile/guard';
8
+
9
+ export const dynamic = 'force-dynamic';
10
+ export const OPTIONS = preflight;
11
+
12
+ /**
13
+ * POST /api/mobile/v1/uploads/sign body: { jobId, contentType, ext? }
14
+ * Returns a short-lived presigned R2 PUT URL. The app uploads the photo
15
+ * bytes directly to R2 (so large images never pass through the serverless
16
+ * function), then records the attachment via POST /jobs/:id/attachments
17
+ * using the returned { key, publicUrl }.
18
+ */
19
+ export async function POST(req: NextRequest) {
20
+ const user = await requireApiUser(req, ['technician', 'dispatcher']);
21
+ if (user instanceof Response) return user;
22
+
23
+ if (!isR2Configured) {
24
+ return jsonError(503, 'File storage (R2) is not configured on this deployment.');
25
+ }
26
+
27
+ const body = (await req.json().catch(() => ({}))) as {
28
+ jobId?: string;
29
+ contentType?: string;
30
+ ext?: string;
31
+ };
32
+ if (!body.jobId || !body.contentType) {
33
+ return jsonError(400, 'jobId and contentType are required');
34
+ }
35
+
36
+ const safeExt = body.ext ? `.${body.ext.replace(/[^a-z0-9]/gi, '').toLowerCase()}` : '';
37
+ const key = `jobs/${body.jobId}/${crypto.randomBytes(8).toString('hex')}${safeExt}`;
38
+
39
+ const uploadUrl = await getSignedUrl(
40
+ r2Client,
41
+ new PutObjectCommand({ Bucket: r2BucketName, Key: key, ContentType: body.contentType }),
42
+ { expiresIn: 600 },
43
+ );
44
+
45
+ return json({ uploadUrl, key, publicUrl: publicUrlFor(key) });
46
+ }
@@ -0,0 +1,21 @@
1
+ import { pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core';
2
+ import { user } from './auth';
3
+
4
+ /**
5
+ * Expo push notification tokens, one row per device a technician signs in
6
+ * on. The field-tech iPhone app registers its Expo push token here on
7
+ * login (POST /api/mobile/v1/devices); the server sends "new job assigned"
8
+ * pushes to every token owned by the assignee.
9
+ */
10
+ export const pushTokens = pgTable('push_tokens', {
11
+ id: uuid('id').primaryKey().defaultRandom(),
12
+ userId: text('user_id')
13
+ .notNull()
14
+ .references(() => user.id, { onDelete: 'cascade' }),
15
+ expoToken: text('expo_token').notNull().unique(),
16
+ platform: text('platform').notNull().default('ios'),
17
+ createdAt: timestamp('created_at').notNull().defaultNow(),
18
+ });
19
+
20
+ export type PushTokenRow = typeof pushTokens.$inferSelect;
21
+ export type NewPushToken = typeof pushTokens.$inferInsert;
@@ -0,0 +1,23 @@
1
+ import { pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core';
2
+ import { user } from './auth';
3
+ import { jobs } from './jobs';
4
+
5
+ /**
6
+ * Labor time entries — one row per clock-in/out a technician does on a job
7
+ * from the field app. An open entry has clockOut = null.
8
+ */
9
+ export const timeEntries = pgTable('time_entries', {
10
+ id: uuid('id').primaryKey().defaultRandom(),
11
+ jobId: uuid('job_id')
12
+ .notNull()
13
+ .references(() => jobs.id, { onDelete: 'cascade' }),
14
+ userId: text('user_id')
15
+ .notNull()
16
+ .references(() => user.id, { onDelete: 'cascade' }),
17
+ clockIn: timestamp('clock_in').notNull().defaultNow(),
18
+ clockOut: timestamp('clock_out'),
19
+ createdAt: timestamp('created_at').notNull().defaultNow(),
20
+ });
21
+
22
+ export type TimeEntryRow = typeof timeEntries.$inferSelect;
23
+ export type NewTimeEntry = typeof timeEntries.$inferInsert;
@@ -0,0 +1,30 @@
1
+ import { NextResponse } from 'next/server';
2
+
3
+ /**
4
+ * Shared JSON + CORS helpers for the mobile API (`/api/mobile/v1/*`).
5
+ *
6
+ * Native apps don't enforce CORS, but Expo Web and the Expo dev client do,
7
+ * so we send permissive headers. These endpoints authenticate with a
8
+ * bearer token (not cookies), so `Access-Control-Allow-Origin: *` is safe —
9
+ * there are no ambient credentials to leak.
10
+ */
11
+ const CORS_HEADERS: Record<string, string> = {
12
+ 'Access-Control-Allow-Origin': '*',
13
+ 'Access-Control-Allow-Methods': 'GET,POST,PUT,DELETE,OPTIONS',
14
+ 'Access-Control-Allow-Headers': 'Content-Type, Authorization',
15
+ };
16
+
17
+ export function json(data: unknown, status = 200): NextResponse {
18
+ const res = NextResponse.json(data, { status });
19
+ for (const [k, v] of Object.entries(CORS_HEADERS)) res.headers.set(k, v);
20
+ return res;
21
+ }
22
+
23
+ export function jsonError(status: number, message: string): NextResponse {
24
+ return json({ error: message }, status);
25
+ }
26
+
27
+ /** Export as the route's `OPTIONS` handler to answer CORS preflight. */
28
+ export function preflight(): NextResponse {
29
+ return new NextResponse(null, { status: 204, headers: CORS_HEADERS });
30
+ }
@@ -0,0 +1,49 @@
1
+ import type { NextRequest } from 'next/server';
2
+ import { auth } from '@/lib/auth';
3
+ import type { AppRole, AppUser } from '@/lib/auth-server';
4
+ import { jsonError } from './cors';
5
+
6
+ function normalizeRole(role: string | null | undefined): AppRole {
7
+ switch (role) {
8
+ case 'admin':
9
+ case 'dispatcher':
10
+ case 'technician':
11
+ case 'csr':
12
+ case 'sales':
13
+ case 'accountant':
14
+ return role;
15
+ default:
16
+ return 'csr';
17
+ }
18
+ }
19
+
20
+ /**
21
+ * Resolves the bearer-token (or cookie) session for a mobile API request.
22
+ * Returns the AppUser on success, or a 401/403 Response to return directly:
23
+ *
24
+ * const user = await requireApiUser(req, ['technician', 'dispatcher']);
25
+ * if (user instanceof Response) return user;
26
+ *
27
+ * `admin` always passes the role check. Pass no roles to allow any signed-in
28
+ * user.
29
+ */
30
+ export async function requireApiUser(
31
+ req: NextRequest,
32
+ roles?: AppRole[],
33
+ ): Promise<AppUser | Response> {
34
+ const session = await auth.api.getSession({ headers: req.headers });
35
+ if (!session?.user) return jsonError(401, 'Unauthorized');
36
+
37
+ const u = session.user as { id: string; email: string; name: string | null; role?: string | null };
38
+ const user: AppUser = {
39
+ id: u.id,
40
+ email: u.email,
41
+ name: u.name,
42
+ role: normalizeRole(u.role),
43
+ };
44
+
45
+ if (roles && roles.length > 0 && user.role !== 'admin' && !roles.includes(user.role)) {
46
+ return jsonError(403, 'Forbidden');
47
+ }
48
+ return user;
49
+ }
@@ -0,0 +1,56 @@
1
+ import 'server-only';
2
+ import { eq } from 'drizzle-orm';
3
+ import { db } from '@/db/client';
4
+ import { pushTokens } from '@/db/schema';
5
+
6
+ export interface PushMessage {
7
+ title: string;
8
+ body: string;
9
+ data?: Record<string, unknown>;
10
+ }
11
+
12
+ /**
13
+ * Sends an Expo push notification to every device a user has registered.
14
+ * Uses Expo's hosted push service — no API key required for basic sends
15
+ * (set EXPO_ACCESS_TOKEN to use an authorized sender). Best-effort: failures
16
+ * are logged, never thrown, so a push problem can't break the calling action.
17
+ *
18
+ * See https://docs.expo.dev/push-notifications/sending-notifications/
19
+ */
20
+ export async function sendPushToUser(userId: string, message: PushMessage): Promise<void> {
21
+ let tokens: { expoToken: string }[] = [];
22
+ try {
23
+ tokens = await db
24
+ .select({ expoToken: pushTokens.expoToken })
25
+ .from(pushTokens)
26
+ .where(eq(pushTokens.userId, userId));
27
+ } catch (err) {
28
+ console.error('[push] failed to load tokens', err);
29
+ return;
30
+ }
31
+ if (tokens.length === 0) return;
32
+
33
+ const payload = tokens.map((t) => ({
34
+ to: t.expoToken,
35
+ sound: 'default',
36
+ title: message.title,
37
+ body: message.body,
38
+ data: message.data ?? {},
39
+ }));
40
+
41
+ try {
42
+ await fetch('https://exp.host/--/api/v2/push/send', {
43
+ method: 'POST',
44
+ headers: {
45
+ 'Content-Type': 'application/json',
46
+ Accept: 'application/json',
47
+ ...(process.env.EXPO_ACCESS_TOKEN
48
+ ? { Authorization: `Bearer ${process.env.EXPO_ACCESS_TOKEN}` }
49
+ : {}),
50
+ },
51
+ body: JSON.stringify(payload),
52
+ });
53
+ } catch (err) {
54
+ console.error('[push] send failed', err);
55
+ }
56
+ }
@@ -0,0 +1,52 @@
1
+ import type { NextRequest } from 'next/server';
2
+ import { db } from '@/db/client';
3
+ import { estimates } from '@/db/schema';
4
+ import type { EstimateLineItem, EstimateStatus } from '@/lib/estimates/types';
5
+ import { json, jsonError, preflight } from '@/lib/mobile/cors';
6
+ import { requireApiUser } from '@/lib/mobile/guard';
7
+
8
+ export const dynamic = 'force-dynamic';
9
+ export const OPTIONS = preflight;
10
+
11
+ /**
12
+ * POST /api/mobile/v1/estimates
13
+ * body: { customerId, lineItems[], notes?, validUntil?, status? }
14
+ * Creates an estimate from the field (mirrors the web createEstimate minus
15
+ * the redirect). Default status 'sent' so the customer can review it.
16
+ */
17
+ export async function POST(req: NextRequest) {
18
+ const user = await requireApiUser(req, ['technician', 'dispatcher']);
19
+ if (user instanceof Response) return user;
20
+
21
+ const body = (await req.json().catch(() => ({}))) as {
22
+ customerId?: string;
23
+ lineItems?: EstimateLineItem[];
24
+ notes?: string;
25
+ validUntil?: string;
26
+ status?: EstimateStatus;
27
+ };
28
+ if (!body.customerId) return jsonError(400, 'customerId is required');
29
+ const lineItems = body.lineItems ?? [];
30
+ if (lineItems.length === 0) return jsonError(400, 'At least one line item is required');
31
+
32
+ const subtotal = lineItems.reduce((acc, li) => acc + li.qty * li.unitPrice, 0);
33
+ const totalCost = lineItems.reduce((acc, li) => acc + li.qty * (li.unitCost ?? 0), 0);
34
+ const status: EstimateStatus = body.status ?? 'sent';
35
+
36
+ const [row] = await db
37
+ .insert(estimates)
38
+ .values({
39
+ customerId: body.customerId,
40
+ status,
41
+ lineItems,
42
+ subtotal,
43
+ total: subtotal,
44
+ totalCost,
45
+ notes: body.notes ?? null,
46
+ validUntil: body.validUntil ? new Date(body.validUntil) : null,
47
+ sentAt: status === 'sent' ? new Date() : null,
48
+ })
49
+ .returning({ id: estimates.id });
50
+
51
+ return json({ estimateId: row.id });
52
+ }
@@ -0,0 +1,14 @@
1
+ import type { NextRequest } from 'next/server';
2
+ import { getPriceBook } from '@/lib/price-book/data';
3
+ import { json, preflight } from '@/lib/mobile/cors';
4
+ import { requireApiUser } from '@/lib/mobile/guard';
5
+
6
+ export const dynamic = 'force-dynamic';
7
+ export const OPTIONS = preflight;
8
+
9
+ /** GET /api/mobile/v1/price-book — categories + items for the field estimate builder. */
10
+ export async function GET(req: NextRequest) {
11
+ const user = await requireApiUser(req, ['technician', 'dispatcher']);
12
+ if (user instanceof Response) return user;
13
+ return json({ categories: await getPriceBook() });
14
+ }
@@ -0,0 +1,32 @@
1
+ import type { NextRequest } from 'next/server';
2
+ import { recordInvoicePayment } from '@/lib/invoices/actions';
3
+ import { PAYMENT_METHODS, type PaymentMethod } from '@/lib/invoices/types';
4
+ import { json, jsonError, preflight } from '@/lib/mobile/cors';
5
+ import { requireApiUser } from '@/lib/mobile/guard';
6
+
7
+ export const dynamic = 'force-dynamic';
8
+ export const OPTIONS = preflight;
9
+
10
+ /**
11
+ * POST /api/mobile/v1/invoices/:id/payments body: { amount, method, note? }
12
+ * Records a manual payment taken in the field (cash/check). Card payments go
13
+ * through the hosted /i/[token] page instead. Reuses recordInvoicePayment,
14
+ * which recomputes the invoice balance + status.
15
+ */
16
+ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
17
+ const user = await requireApiUser(req, ['technician', 'dispatcher']);
18
+ if (user instanceof Response) return user;
19
+ const { id } = await params;
20
+
21
+ const body = (await req.json().catch(() => ({}))) as {
22
+ amount?: number;
23
+ method?: string;
24
+ note?: string;
25
+ };
26
+ if (!body.amount || body.amount <= 0) return jsonError(400, 'A positive amount (cents) is required');
27
+ const method = (body.method ?? 'cash') as PaymentMethod;
28
+ if (!PAYMENT_METHODS.includes(method)) return jsonError(400, 'Invalid payment method');
29
+
30
+ await recordInvoicePayment({ invoiceId: id, amount: body.amount, method, note: body.note });
31
+ return json({ ok: true });
32
+ }
@@ -0,0 +1,28 @@
1
+ import type { NextRequest } from 'next/server';
2
+ import { eq } from 'drizzle-orm';
3
+ import { db } from '@/db/client';
4
+ import { invoices } from '@/db/schema';
5
+ import { getInvoice } from '@/lib/invoices/data';
6
+ import { json, jsonError, preflight } from '@/lib/mobile/cors';
7
+ import { requireApiUser } from '@/lib/mobile/guard';
8
+
9
+ export const dynamic = 'force-dynamic';
10
+ export const OPTIONS = preflight;
11
+
12
+ /** GET /api/mobile/v1/invoices/:id — full invoice + payments + pay link. */
13
+ export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
14
+ const user = await requireApiUser(req, ['technician', 'dispatcher']);
15
+ if (user instanceof Response) return user;
16
+ const { id } = await params;
17
+
18
+ const invoice = await getInvoice(id);
19
+ if (!invoice) return jsonError(404, 'Invoice not found');
20
+
21
+ const [row] = await db
22
+ .select({ publicToken: invoices.publicToken })
23
+ .from(invoices)
24
+ .where(eq(invoices.id, id))
25
+ .limit(1);
26
+
27
+ return json({ invoice, payPath: row ? `/i/${row.publicToken}` : null });
28
+ }
@@ -0,0 +1,82 @@
1
+ import crypto from 'node:crypto';
2
+ import type { NextRequest } from 'next/server';
3
+ import { desc, eq } from 'drizzle-orm';
4
+ import { db } from '@/db/client';
5
+ import { invoices, jobs } from '@/db/schema';
6
+ import { getInvoice } from '@/lib/invoices/data';
7
+ import type { InvoiceLineItem } from '@/lib/invoices/types';
8
+ import { json, jsonError, preflight } from '@/lib/mobile/cors';
9
+ import { requireApiUser } from '@/lib/mobile/guard';
10
+
11
+ export const dynamic = 'force-dynamic';
12
+ export const OPTIONS = preflight;
13
+
14
+ function genInvoiceNumber(): string {
15
+ const year = new Date().getUTCFullYear();
16
+ return `INV-${year}-${crypto.randomBytes(3).toString('hex').toUpperCase()}`;
17
+ }
18
+
19
+ /** GET /api/mobile/v1/jobs/:id/invoice — the latest invoice for a job (or null). */
20
+ export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
21
+ const user = await requireApiUser(req, ['technician', 'dispatcher']);
22
+ if (user instanceof Response) return user;
23
+ const { id } = await params;
24
+
25
+ const [row] = await db
26
+ .select({ id: invoices.id, publicToken: invoices.publicToken })
27
+ .from(invoices)
28
+ .where(eq(invoices.jobId, id))
29
+ .orderBy(desc(invoices.createdAt))
30
+ .limit(1);
31
+ if (!row) return json({ invoice: null });
32
+
33
+ const invoice = await getInvoice(row.id);
34
+ return json({ invoice, payPath: `/i/${row.publicToken}` });
35
+ }
36
+
37
+ /**
38
+ * POST /api/mobile/v1/jobs/:id/invoice
39
+ * Generates an invoice from the job's line items (mirrors the web
40
+ * createInvoiceFromJob, minus the redirect). Returns the pay link the app
41
+ * opens for card payment.
42
+ */
43
+ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
44
+ const user = await requireApiUser(req, ['technician', 'dispatcher']);
45
+ if (user instanceof Response) return user;
46
+ const { id } = await params;
47
+
48
+ const [job] = await db.select().from(jobs).where(eq(jobs.id, id)).limit(1);
49
+ if (!job) return jsonError(404, 'Job not found');
50
+
51
+ const lineItems = (job.lineItems as InvoiceLineItem[]).map((li) => ({
52
+ description: li.description,
53
+ qty: li.qty,
54
+ unitPrice: li.unitPrice,
55
+ taxable: true,
56
+ }));
57
+ const total = lineItems.reduce((acc, li) => acc + li.qty * li.unitPrice, 0);
58
+ if (total <= 0) return jsonError(400, 'Job has no priced line items to invoice.');
59
+
60
+ const dueDate = new Date();
61
+ dueDate.setDate(dueDate.getDate() + 30);
62
+ const publicToken = crypto.randomBytes(16).toString('hex');
63
+
64
+ const [row] = await db
65
+ .insert(invoices)
66
+ .values({
67
+ invoiceNumber: genInvoiceNumber(),
68
+ customerId: job.customerId,
69
+ jobId: job.id,
70
+ status: 'sent',
71
+ lineItems,
72
+ subtotal: total,
73
+ total,
74
+ amountPaid: 0,
75
+ publicToken,
76
+ dueDate,
77
+ sentAt: new Date(),
78
+ })
79
+ .returning({ id: invoices.id });
80
+
81
+ return json({ invoiceId: row.id, publicToken, payPath: `/i/${publicToken}` });
82
+ }
@@ -0,0 +1,32 @@
1
+ import type { NextRequest } from 'next/server';
2
+ import { sendSmsRaw } from '@/lib/sms/actions';
3
+ import { json, jsonError, preflight } from '@/lib/mobile/cors';
4
+ import { requireApiUser } from '@/lib/mobile/guard';
5
+
6
+ export const dynamic = 'force-dynamic';
7
+ export const OPTIONS = preflight;
8
+
9
+ /**
10
+ * POST /api/mobile/v1/sms/send body: { to, body, customerId? }
11
+ * Sends an SMS via Twilio (reuses sendSmsRaw, which also persists the
12
+ * outbound message). 503 if Twilio isn't configured on this deployment.
13
+ */
14
+ export async function POST(req: NextRequest) {
15
+ const user = await requireApiUser(req, ['technician', 'dispatcher']);
16
+ if (user instanceof Response) return user;
17
+
18
+ const data = (await req.json().catch(() => ({}))) as {
19
+ to?: string;
20
+ body?: string;
21
+ customerId?: string;
22
+ };
23
+ if (!data.to || !data.body) return jsonError(400, 'to and body are required');
24
+
25
+ try {
26
+ await sendSmsRaw({ to: data.to, body: data.body, customerId: data.customerId });
27
+ } catch (err) {
28
+ const msg = err instanceof Error ? err.message : 'Send failed';
29
+ return jsonError(msg.includes('not configured') ? 503 : 500, msg);
30
+ }
31
+ return json({ ok: true });
32
+ }
@@ -0,0 +1,15 @@
1
+ import type { NextRequest } from 'next/server';
2
+ import { getMessagesForCustomer } from '@/lib/sms/data';
3
+ import { json, preflight } from '@/lib/mobile/cors';
4
+ import { requireApiUser } from '@/lib/mobile/guard';
5
+
6
+ export const dynamic = 'force-dynamic';
7
+ export const OPTIONS = preflight;
8
+
9
+ /** GET /api/mobile/v1/sms/threads/:customerId — messages in one conversation. */
10
+ export async function GET(req: NextRequest, { params }: { params: Promise<{ customerId: string }> }) {
11
+ const user = await requireApiUser(req, ['technician', 'dispatcher']);
12
+ if (user instanceof Response) return user;
13
+ const { customerId } = await params;
14
+ return json({ messages: await getMessagesForCustomer(customerId) });
15
+ }
@@ -0,0 +1,14 @@
1
+ import type { NextRequest } from 'next/server';
2
+ import { getSmsThreads } from '@/lib/sms/data';
3
+ import { json, preflight } from '@/lib/mobile/cors';
4
+ import { requireApiUser } from '@/lib/mobile/guard';
5
+
6
+ export const dynamic = 'force-dynamic';
7
+ export const OPTIONS = preflight;
8
+
9
+ /** GET /api/mobile/v1/sms/threads — one row per customer conversation. */
10
+ export async function GET(req: NextRequest) {
11
+ const user = await requireApiUser(req, ['technician', 'dispatcher']);
12
+ if (user instanceof Response) return user;
13
+ return json({ threads: await getSmsThreads() });
14
+ }