@pylonsync/create-pylon 0.3.344 → 0.3.346

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 (295) hide show
  1. package/bin/create-pylon.js +35 -0
  2. package/package.json +1 -1
  3. package/templates/crm/AGENTS.md +116 -0
  4. package/templates/crm/CLAUDE.md +7 -0
  5. package/templates/crm/README.md +96 -0
  6. package/templates/crm/app/companies/companies-view.tsx +129 -0
  7. package/templates/crm/app/companies/page.tsx +17 -0
  8. package/templates/crm/app/contacts/contacts-view.tsx +139 -0
  9. package/templates/crm/app/contacts/page.tsx +17 -0
  10. package/templates/crm/app/deals/[id]/deal-view.tsx +189 -0
  11. package/templates/crm/app/deals/[id]/page.tsx +23 -0
  12. package/templates/crm/app/error.tsx +43 -0
  13. package/templates/crm/app/globals.css +201 -0
  14. package/templates/crm/app/layout.tsx +31 -0
  15. package/templates/crm/app/login/auth-form.tsx +122 -0
  16. package/templates/crm/app/login/page.tsx +38 -0
  17. package/templates/crm/app/not-found.tsx +29 -0
  18. package/templates/crm/app/page.tsx +38 -0
  19. package/templates/crm/app/pipeline-view.tsx +99 -0
  20. package/templates/crm/app/robots.ts +12 -0
  21. package/templates/crm/app/sitemap.ts +27 -0
  22. package/templates/crm/app/workspace.tsx +231 -0
  23. package/templates/crm/app.ts +171 -0
  24. package/templates/crm/bunfig +4 -0
  25. package/templates/crm/components/activity-timeline.tsx +133 -0
  26. package/templates/crm/components/avatar.tsx +40 -0
  27. package/templates/crm/components/board-skeleton.tsx +44 -0
  28. package/templates/crm/components/command-palette.tsx +162 -0
  29. package/templates/crm/components/data-table.tsx +90 -0
  30. package/templates/crm/components/deal-card.tsx +91 -0
  31. package/templates/crm/components/deal-dialog.tsx +169 -0
  32. package/templates/crm/components/empty-state.tsx +43 -0
  33. package/templates/crm/components/kbd.tsx +23 -0
  34. package/templates/crm/components/metrics-bar.tsx +46 -0
  35. package/templates/crm/components/page-header.tsx +36 -0
  36. package/templates/crm/components/pipeline-board.tsx +105 -0
  37. package/templates/crm/components/record-dialog.tsx +126 -0
  38. package/templates/crm/components/sidebar.tsx +112 -0
  39. package/templates/crm/components/stage-badge.tsx +33 -0
  40. package/templates/crm/components/ui/badge.tsx +37 -0
  41. package/templates/crm/components/ui/button.tsx +56 -0
  42. package/templates/crm/components/ui/card.tsx +90 -0
  43. package/templates/crm/components/ui/dialog.tsx +110 -0
  44. package/templates/crm/components/ui/input.tsx +30 -0
  45. package/templates/crm/components/ui/label.tsx +21 -0
  46. package/templates/crm/components/ui/select.tsx +39 -0
  47. package/templates/crm/components/ui/table.tsx +124 -0
  48. package/templates/crm/components/ui/textarea.tsx +24 -0
  49. package/templates/crm/components.json +20 -0
  50. package/templates/crm/functions/createDeal.ts +59 -0
  51. package/templates/crm/functions/logActivity.ts +49 -0
  52. package/templates/crm/functions/moveDeal.ts +53 -0
  53. package/templates/crm/functions/seedWorkspace.ts +63 -0
  54. package/templates/crm/gitignore +10 -0
  55. package/templates/crm/lib/pipeline.ts +221 -0
  56. package/templates/crm/lib/search.ts +78 -0
  57. package/templates/crm/lib/seed.ts +130 -0
  58. package/templates/crm/lib/utils.ts +10 -0
  59. package/templates/crm/package.json +39 -0
  60. package/templates/crm/tests/components.test.tsx +245 -0
  61. package/templates/crm/tests/pipeline.test.ts +219 -0
  62. package/templates/crm/tests/search.test.ts +83 -0
  63. package/templates/crm/tests/setup.ts +5 -0
  64. package/templates/crm/tsconfig.json +33 -0
  65. package/templates/helpdesk/AGENTS.md +116 -0
  66. package/templates/helpdesk/CLAUDE.md +7 -0
  67. package/templates/helpdesk/README.md +90 -0
  68. package/templates/helpdesk/app/customers/customers-view.tsx +140 -0
  69. package/templates/helpdesk/app/customers/page.tsx +17 -0
  70. package/templates/helpdesk/app/error.tsx +43 -0
  71. package/templates/helpdesk/app/globals.css +201 -0
  72. package/templates/helpdesk/app/inbox-view.tsx +147 -0
  73. package/templates/helpdesk/app/layout.tsx +31 -0
  74. package/templates/helpdesk/app/login/auth-form.tsx +122 -0
  75. package/templates/helpdesk/app/login/page.tsx +38 -0
  76. package/templates/helpdesk/app/not-found.tsx +29 -0
  77. package/templates/helpdesk/app/page.tsx +38 -0
  78. package/templates/helpdesk/app/robots.ts +12 -0
  79. package/templates/helpdesk/app/sitemap.ts +27 -0
  80. package/templates/helpdesk/app/tickets/[id]/page.tsx +23 -0
  81. package/templates/helpdesk/app/tickets/[id]/ticket-view.tsx +164 -0
  82. package/templates/helpdesk/app/workspace.tsx +210 -0
  83. package/templates/helpdesk/app.ts +142 -0
  84. package/templates/helpdesk/bunfig +4 -0
  85. package/templates/helpdesk/components/avatar.tsx +40 -0
  86. package/templates/helpdesk/components/command-palette.tsx +162 -0
  87. package/templates/helpdesk/components/data-table.tsx +90 -0
  88. package/templates/helpdesk/components/empty-state.tsx +43 -0
  89. package/templates/helpdesk/components/kbd.tsx +23 -0
  90. package/templates/helpdesk/components/new-ticket-dialog.tsx +147 -0
  91. package/templates/helpdesk/components/page-header.tsx +36 -0
  92. package/templates/helpdesk/components/priority-badge.tsx +61 -0
  93. package/templates/helpdesk/components/record-dialog.tsx +126 -0
  94. package/templates/helpdesk/components/sidebar.tsx +111 -0
  95. package/templates/helpdesk/components/sla-indicator.tsx +47 -0
  96. package/templates/helpdesk/components/thread.tsx +143 -0
  97. package/templates/helpdesk/components/ticket-list.tsx +78 -0
  98. package/templates/helpdesk/components/ui/badge.tsx +37 -0
  99. package/templates/helpdesk/components/ui/button.tsx +56 -0
  100. package/templates/helpdesk/components/ui/card.tsx +90 -0
  101. package/templates/helpdesk/components/ui/dialog.tsx +110 -0
  102. package/templates/helpdesk/components/ui/input.tsx +30 -0
  103. package/templates/helpdesk/components/ui/label.tsx +21 -0
  104. package/templates/helpdesk/components/ui/select.tsx +39 -0
  105. package/templates/helpdesk/components/ui/table.tsx +124 -0
  106. package/templates/helpdesk/components/ui/textarea.tsx +24 -0
  107. package/templates/helpdesk/components.json +20 -0
  108. package/templates/helpdesk/functions/createTicket.ts +62 -0
  109. package/templates/helpdesk/functions/replyToTicket.ts +55 -0
  110. package/templates/helpdesk/functions/seedWorkspace.ts +52 -0
  111. package/templates/helpdesk/functions/setTicketState.ts +58 -0
  112. package/templates/helpdesk/gitignore +10 -0
  113. package/templates/helpdesk/lib/format.ts +53 -0
  114. package/templates/helpdesk/lib/search.ts +78 -0
  115. package/templates/helpdesk/lib/seed.ts +185 -0
  116. package/templates/helpdesk/lib/tickets.ts +184 -0
  117. package/templates/helpdesk/lib/utils.ts +10 -0
  118. package/templates/helpdesk/package.json +39 -0
  119. package/templates/helpdesk/tests/components.test.tsx +202 -0
  120. package/templates/helpdesk/tests/search.test.ts +83 -0
  121. package/templates/helpdesk/tests/setup.ts +5 -0
  122. package/templates/helpdesk/tests/tickets.test.ts +215 -0
  123. package/templates/helpdesk/tsconfig.json +33 -0
  124. package/templates/inventory/AGENTS.md +116 -0
  125. package/templates/inventory/CLAUDE.md +7 -0
  126. package/templates/inventory/README.md +103 -0
  127. package/templates/inventory/app/error.tsx +43 -0
  128. package/templates/inventory/app/globals.css +201 -0
  129. package/templates/inventory/app/layout.tsx +31 -0
  130. package/templates/inventory/app/login/auth-form.tsx +122 -0
  131. package/templates/inventory/app/login/page.tsx +38 -0
  132. package/templates/inventory/app/movements/movements-view.tsx +103 -0
  133. package/templates/inventory/app/movements/page.tsx +17 -0
  134. package/templates/inventory/app/not-found.tsx +29 -0
  135. package/templates/inventory/app/page.tsx +34 -0
  136. package/templates/inventory/app/products/[id]/page.tsx +23 -0
  137. package/templates/inventory/app/products/[id]/product-view.tsx +178 -0
  138. package/templates/inventory/app/products-view.tsx +213 -0
  139. package/templates/inventory/app/robots.ts +12 -0
  140. package/templates/inventory/app/sitemap.ts +27 -0
  141. package/templates/inventory/app/workspace.tsx +172 -0
  142. package/templates/inventory/app.ts +154 -0
  143. package/templates/inventory/bunfig +4 -0
  144. package/templates/inventory/components/avatar.tsx +40 -0
  145. package/templates/inventory/components/command-palette.tsx +162 -0
  146. package/templates/inventory/components/data-table.tsx +90 -0
  147. package/templates/inventory/components/empty-state.tsx +43 -0
  148. package/templates/inventory/components/kbd.tsx +23 -0
  149. package/templates/inventory/components/movement-dialog.tsx +182 -0
  150. package/templates/inventory/components/page-header.tsx +36 -0
  151. package/templates/inventory/components/record-dialog.tsx +126 -0
  152. package/templates/inventory/components/sidebar.tsx +111 -0
  153. package/templates/inventory/components/stock-level.tsx +47 -0
  154. package/templates/inventory/components/summary-bar.tsx +56 -0
  155. package/templates/inventory/components/ui/badge.tsx +37 -0
  156. package/templates/inventory/components/ui/button.tsx +56 -0
  157. package/templates/inventory/components/ui/card.tsx +90 -0
  158. package/templates/inventory/components/ui/dialog.tsx +110 -0
  159. package/templates/inventory/components/ui/input.tsx +30 -0
  160. package/templates/inventory/components/ui/label.tsx +21 -0
  161. package/templates/inventory/components/ui/select.tsx +39 -0
  162. package/templates/inventory/components/ui/table.tsx +124 -0
  163. package/templates/inventory/components/ui/textarea.tsx +24 -0
  164. package/templates/inventory/components.json +20 -0
  165. package/templates/inventory/functions/recordMovement.ts +72 -0
  166. package/templates/inventory/functions/seedWorkspace.ts +40 -0
  167. package/templates/inventory/gitignore +10 -0
  168. package/templates/inventory/lib/format.ts +53 -0
  169. package/templates/inventory/lib/search.ts +78 -0
  170. package/templates/inventory/lib/seed.ts +102 -0
  171. package/templates/inventory/lib/stock.ts +207 -0
  172. package/templates/inventory/lib/utils.ts +10 -0
  173. package/templates/inventory/package.json +39 -0
  174. package/templates/inventory/tests/components.test.tsx +155 -0
  175. package/templates/inventory/tests/search.test.ts +83 -0
  176. package/templates/inventory/tests/setup.ts +5 -0
  177. package/templates/inventory/tests/stock.test.ts +255 -0
  178. package/templates/inventory/tsconfig.json +33 -0
  179. package/templates/invoices/AGENTS.md +116 -0
  180. package/templates/invoices/CLAUDE.md +7 -0
  181. package/templates/invoices/README.md +99 -0
  182. package/templates/invoices/app/clients/clients-view.tsx +130 -0
  183. package/templates/invoices/app/clients/page.tsx +17 -0
  184. package/templates/invoices/app/error.tsx +43 -0
  185. package/templates/invoices/app/globals.css +201 -0
  186. package/templates/invoices/app/invoices/[id]/invoice-view.tsx +253 -0
  187. package/templates/invoices/app/invoices/[id]/page.tsx +23 -0
  188. package/templates/invoices/app/invoices-view.tsx +183 -0
  189. package/templates/invoices/app/layout.tsx +31 -0
  190. package/templates/invoices/app/login/auth-form.tsx +122 -0
  191. package/templates/invoices/app/login/page.tsx +38 -0
  192. package/templates/invoices/app/not-found.tsx +29 -0
  193. package/templates/invoices/app/page.tsx +36 -0
  194. package/templates/invoices/app/robots.ts +12 -0
  195. package/templates/invoices/app/sitemap.ts +27 -0
  196. package/templates/invoices/app/workspace.tsx +174 -0
  197. package/templates/invoices/app.ts +169 -0
  198. package/templates/invoices/bunfig +4 -0
  199. package/templates/invoices/components/avatar.tsx +40 -0
  200. package/templates/invoices/components/command-palette.tsx +162 -0
  201. package/templates/invoices/components/data-table.tsx +90 -0
  202. package/templates/invoices/components/empty-state.tsx +43 -0
  203. package/templates/invoices/components/kbd.tsx +23 -0
  204. package/templates/invoices/components/line-items.tsx +172 -0
  205. package/templates/invoices/components/page-header.tsx +36 -0
  206. package/templates/invoices/components/payment-dialog.tsx +132 -0
  207. package/templates/invoices/components/record-dialog.tsx +126 -0
  208. package/templates/invoices/components/sidebar.tsx +111 -0
  209. package/templates/invoices/components/status-badge.tsx +35 -0
  210. package/templates/invoices/components/summary-bar.tsx +65 -0
  211. package/templates/invoices/components/totals-panel.tsx +70 -0
  212. package/templates/invoices/components/ui/badge.tsx +37 -0
  213. package/templates/invoices/components/ui/button.tsx +56 -0
  214. package/templates/invoices/components/ui/card.tsx +90 -0
  215. package/templates/invoices/components/ui/dialog.tsx +110 -0
  216. package/templates/invoices/components/ui/input.tsx +30 -0
  217. package/templates/invoices/components/ui/label.tsx +21 -0
  218. package/templates/invoices/components/ui/select.tsx +39 -0
  219. package/templates/invoices/components/ui/table.tsx +124 -0
  220. package/templates/invoices/components/ui/textarea.tsx +24 -0
  221. package/templates/invoices/components.json +20 -0
  222. package/templates/invoices/functions/createInvoice.ts +49 -0
  223. package/templates/invoices/functions/recordPayment.ts +72 -0
  224. package/templates/invoices/functions/seedWorkspace.ts +57 -0
  225. package/templates/invoices/functions/setInvoiceStatus.ts +35 -0
  226. package/templates/invoices/gitignore +10 -0
  227. package/templates/invoices/lib/billing.ts +259 -0
  228. package/templates/invoices/lib/format.ts +53 -0
  229. package/templates/invoices/lib/search.ts +78 -0
  230. package/templates/invoices/lib/seed.ts +190 -0
  231. package/templates/invoices/lib/utils.ts +10 -0
  232. package/templates/invoices/package.json +39 -0
  233. package/templates/invoices/tests/billing.test.ts +283 -0
  234. package/templates/invoices/tests/components.test.tsx +172 -0
  235. package/templates/invoices/tests/search.test.ts +83 -0
  236. package/templates/invoices/tests/setup.ts +5 -0
  237. package/templates/invoices/tsconfig.json +33 -0
  238. package/templates/projects/AGENTS.md +116 -0
  239. package/templates/projects/CLAUDE.md +7 -0
  240. package/templates/projects/README.md +96 -0
  241. package/templates/projects/app/clients/clients-view.tsx +126 -0
  242. package/templates/projects/app/clients/page.tsx +17 -0
  243. package/templates/projects/app/error.tsx +43 -0
  244. package/templates/projects/app/globals.css +201 -0
  245. package/templates/projects/app/layout.tsx +31 -0
  246. package/templates/projects/app/login/auth-form.tsx +122 -0
  247. package/templates/projects/app/login/page.tsx +38 -0
  248. package/templates/projects/app/not-found.tsx +29 -0
  249. package/templates/projects/app/page.tsx +30 -0
  250. package/templates/projects/app/projects/[id]/page.tsx +23 -0
  251. package/templates/projects/app/projects/[id]/project-view.tsx +179 -0
  252. package/templates/projects/app/projects-view.tsx +181 -0
  253. package/templates/projects/app/robots.ts +12 -0
  254. package/templates/projects/app/sitemap.ts +27 -0
  255. package/templates/projects/app/workspace.tsx +190 -0
  256. package/templates/projects/app.ts +186 -0
  257. package/templates/projects/bunfig +4 -0
  258. package/templates/projects/components/avatar.tsx +40 -0
  259. package/templates/projects/components/board-skeleton.tsx +37 -0
  260. package/templates/projects/components/budget-bar.tsx +61 -0
  261. package/templates/projects/components/command-palette.tsx +162 -0
  262. package/templates/projects/components/data-table.tsx +90 -0
  263. package/templates/projects/components/empty-state.tsx +43 -0
  264. package/templates/projects/components/kbd.tsx +23 -0
  265. package/templates/projects/components/page-header.tsx +36 -0
  266. package/templates/projects/components/record-dialog.tsx +126 -0
  267. package/templates/projects/components/sidebar.tsx +111 -0
  268. package/templates/projects/components/task-board.tsx +109 -0
  269. package/templates/projects/components/task-card.tsx +77 -0
  270. package/templates/projects/components/time-dialog.tsx +114 -0
  271. package/templates/projects/components/ui/badge.tsx +37 -0
  272. package/templates/projects/components/ui/button.tsx +56 -0
  273. package/templates/projects/components/ui/card.tsx +90 -0
  274. package/templates/projects/components/ui/dialog.tsx +110 -0
  275. package/templates/projects/components/ui/input.tsx +30 -0
  276. package/templates/projects/components/ui/label.tsx +21 -0
  277. package/templates/projects/components/ui/select.tsx +39 -0
  278. package/templates/projects/components/ui/table.tsx +124 -0
  279. package/templates/projects/components/ui/textarea.tsx +24 -0
  280. package/templates/projects/components.json +20 -0
  281. package/templates/projects/functions/logTime.ts +49 -0
  282. package/templates/projects/functions/moveTask.ts +40 -0
  283. package/templates/projects/functions/seedWorkspace.ts +59 -0
  284. package/templates/projects/gitignore +10 -0
  285. package/templates/projects/lib/format.ts +53 -0
  286. package/templates/projects/lib/search.ts +78 -0
  287. package/templates/projects/lib/seed.ts +140 -0
  288. package/templates/projects/lib/utils.ts +10 -0
  289. package/templates/projects/lib/work.ts +233 -0
  290. package/templates/projects/package.json +39 -0
  291. package/templates/projects/tests/components.test.tsx +200 -0
  292. package/templates/projects/tests/search.test.ts +83 -0
  293. package/templates/projects/tests/setup.ts +5 -0
  294. package/templates/projects/tests/work.test.ts +238 -0
  295. package/templates/projects/tsconfig.json +33 -0
@@ -0,0 +1,55 @@
1
+ import { mutation, v } from "@pylonsync/functions";
2
+
3
+ /**
4
+ * Post an agent message on a ticket.
5
+ *
6
+ * A mutation rather than a bare insert because a reply changes the ticket too:
7
+ * the first public reply stamps `firstRespondedAt`, which is what the SLA state
8
+ * is computed from. Doing that client-side would let a slow or offline tab
9
+ * decide when the clock stopped.
10
+ *
11
+ * An INTERNAL note deliberately does NOT stop the clock — the customer is still
12
+ * waiting, and a team that could clear its SLA by talking to itself would have
13
+ * an SLA worth nothing.
14
+ */
15
+ export default mutation<
16
+ { ticketId: string; body: string; internal?: boolean },
17
+ { ok: true; id: string }
18
+ >({
19
+ auth: "user",
20
+ args: {
21
+ ticketId: v.id("Ticket"),
22
+ body: v.string(),
23
+ internal: v.optional(v.boolean()),
24
+ },
25
+ async handler(ctx, args) {
26
+ const body = args.body.trim();
27
+ if (!body) throw ctx.error("INVALID_ARGS", "A reply needs a body.");
28
+
29
+ const ticket = await ctx.db.get("Ticket", args.ticketId);
30
+ if (!ticket) throw ctx.error("NOT_FOUND", "Ticket not found.");
31
+
32
+ const internal = args.internal ?? false;
33
+ const now = new Date().toISOString();
34
+
35
+ const id = await ctx.db.insert("Message", {
36
+ ticketId: args.ticketId,
37
+ body,
38
+ fromCustomer: false,
39
+ internal,
40
+ authorId: ctx.auth.userId,
41
+ createdAt: now,
42
+ });
43
+
44
+ const patch: Record<string, unknown> = { updatedAt: now };
45
+ if (!internal && !ticket.firstRespondedAt) {
46
+ patch.firstRespondedAt = now;
47
+ }
48
+ // Replying to something nobody owns takes ownership — the common case, and
49
+ // it stops two agents answering the same ticket.
50
+ if (!ticket.assigneeId) patch.assigneeId = ctx.auth.userId;
51
+ await ctx.db.update("Ticket", args.ticketId, patch);
52
+
53
+ return { ok: true, id: id as string } as const;
54
+ },
55
+ });
@@ -0,0 +1,52 @@
1
+ import { mutation } from "@pylonsync/functions";
2
+ import { shapeSeed } from "../lib/seed";
3
+
4
+ /**
5
+ * Fill a brand-new helpdesk with a realistic queue, once.
6
+ *
7
+ * An empty inbox demonstrates nothing about triage, SLA, or the thread view.
8
+ * The client calls this after sign-in; it returns immediately if any ticket
9
+ * already exists, so it is safe on every load and can never duplicate the
10
+ * fixtures or touch real data.
11
+ *
12
+ * Delete this function and lib/seed.ts once real tickets arrive.
13
+ */
14
+ export default mutation<Record<string, never>, { seeded: boolean }>({
15
+ auth: "user",
16
+ args: {},
17
+ async handler(ctx) {
18
+ const existing = await ctx.db.query("Ticket", { $limit: 1 });
19
+ if (existing.length > 0) return { seeded: false };
20
+
21
+ const seed = shapeSeed();
22
+ const me = ctx.auth.userId;
23
+
24
+ const customerIds = new Map<string, string>();
25
+ for (const customer of seed.customers) {
26
+ const id = await ctx.db.insert("Customer", customer.row);
27
+ customerIds.set(customer.key, id as string);
28
+ }
29
+
30
+ const ticketIds = new Map<string, string>();
31
+ for (const ticket of seed.tickets) {
32
+ const id = await ctx.db.insert("Ticket", {
33
+ ...ticket.row,
34
+ customerId: customerIds.get(ticket.customer) ?? null,
35
+ // Answered tickets are assigned to whoever is seeding; the unanswered
36
+ // ones stay unassigned so the queue has something to pick up.
37
+ assigneeId: ticket.row.firstRespondedAt ? me : null,
38
+ });
39
+ ticketIds.set(ticket.key, id as string);
40
+ }
41
+
42
+ for (const message of seed.messages) {
43
+ await ctx.db.insert("Message", {
44
+ ...message.row,
45
+ ticketId: ticketIds.get(message.ticket) ?? null,
46
+ authorId: message.row.fromCustomer ? null : me,
47
+ });
48
+ }
49
+
50
+ return { seeded: true };
51
+ },
52
+ });
@@ -0,0 +1,58 @@
1
+ import { mutation, v } from "@pylonsync/functions";
2
+ import { isValidPriority, isValidStatus } from "../lib/tickets";
3
+
4
+ /**
5
+ * Change a ticket's status, priority, or assignee.
6
+ *
7
+ * One mutation for all three because they share the same validation and the
8
+ * same `updatedAt` bump, and because a queue that accepts an unknown status
9
+ * strands the ticket in a filter nothing renders. Every value is checked
10
+ * server-side — the UI only ever offers known ones, but the endpoint is
11
+ * reachable directly.
12
+ *
13
+ * `assigneeId: null` explicitly unassigns; omitting it leaves the owner alone.
14
+ */
15
+ export default mutation<
16
+ {
17
+ ticketId: string;
18
+ status?: string;
19
+ priority?: string;
20
+ assigneeId?: string | null;
21
+ },
22
+ { ok: true }
23
+ >({
24
+ auth: "user",
25
+ args: {
26
+ ticketId: v.id("Ticket"),
27
+ status: v.optional(v.string()),
28
+ priority: v.optional(v.string()),
29
+ assigneeId: v.optional(v.id("User")),
30
+ },
31
+ async handler(ctx, args) {
32
+ const ticket = await ctx.db.get("Ticket", args.ticketId);
33
+ if (!ticket) throw ctx.error("NOT_FOUND", "Ticket not found.");
34
+
35
+ const patch: Record<string, unknown> = { updatedAt: new Date().toISOString() };
36
+
37
+ if (args.status !== undefined) {
38
+ if (!isValidStatus(args.status)) {
39
+ throw ctx.error("INVALID_ARGS", `Unknown status "${args.status}".`);
40
+ }
41
+ patch.status = args.status;
42
+ }
43
+
44
+ if (args.priority !== undefined) {
45
+ if (!isValidPriority(args.priority)) {
46
+ throw ctx.error("INVALID_ARGS", `Unknown priority "${args.priority}".`);
47
+ }
48
+ patch.priority = args.priority;
49
+ }
50
+
51
+ if (args.assigneeId !== undefined) {
52
+ patch.assigneeId = args.assigneeId || null;
53
+ }
54
+
55
+ await ctx.db.update("Ticket", args.ticketId, patch);
56
+ return { ok: true } as const;
57
+ },
58
+ });
@@ -0,0 +1,10 @@
1
+ node_modules/
2
+ .pylon/
3
+ pylon.manifest.json
4
+ pylon.client.ts
5
+ web/dist/
6
+ *.db
7
+ *.db-*
8
+ .env
9
+ .env.local
10
+ .DS_Store
@@ -0,0 +1,53 @@
1
+ // Small display helpers shared across views. Pure, so the timeline and the
2
+ // queue can't drift on how they render the same instant.
3
+
4
+ /** Initials for an avatar: "Dana Whitfield" → "DW", "jordan@x.com" → "JO". */
5
+ export function initials(nameOrEmail: string | null | undefined): string {
6
+ const raw = (nameOrEmail ?? "").trim();
7
+ if (!raw) return "?";
8
+ const name = raw.includes("@") ? raw.split("@")[0] : raw;
9
+ const parts = name.split(/[\s._-]+/).filter(Boolean);
10
+ if (parts.length === 0) return "?";
11
+ if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
12
+ return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
13
+ }
14
+
15
+ /** A stable accent per record, so the same person keeps their colour. */
16
+ export function accentIndex(seed: string, buckets = 6): number {
17
+ let hash = 0;
18
+ for (let i = 0; i < seed.length; i += 1) {
19
+ hash = (hash * 31 + seed.charCodeAt(i)) | 0;
20
+ }
21
+ return Math.abs(hash) % buckets;
22
+ }
23
+
24
+ /** "just now" / "4h ago" / "Mar 3". */
25
+ export function relativeTime(
26
+ iso: string | null | undefined,
27
+ now: number = Date.now(),
28
+ ): string {
29
+ if (!iso) return "";
30
+ const then = Date.parse(iso);
31
+ if (!Number.isFinite(then)) return "";
32
+ const seconds = Math.round((now - then) / 1000);
33
+ if (seconds < 45) return "just now";
34
+ const minutes = Math.round(seconds / 60);
35
+ if (minutes < 60) return `${minutes}m ago`;
36
+ const hours = Math.round(minutes / 60);
37
+ if (hours < 24) return `${hours}h ago`;
38
+ const days = Math.round(hours / 24);
39
+ if (days < 7) return `${days}d ago`;
40
+ return new Date(then).toLocaleDateString("en-US", { month: "short", day: "numeric" });
41
+ }
42
+
43
+ /** "3h 20m" / "45m" / "2d" — a duration an agent reads at a glance. */
44
+ export function duration(minutes: number): string {
45
+ const abs = Math.abs(Math.round(minutes));
46
+ if (abs < 60) return `${abs}m`;
47
+ if (abs < 1440) {
48
+ const hours = Math.floor(abs / 60);
49
+ const rest = abs % 60;
50
+ return rest === 0 ? `${hours}h` : `${hours}h ${rest}m`;
51
+ }
52
+ return `${Math.floor(abs / 1440)}d`;
53
+ }
@@ -0,0 +1,78 @@
1
+ // Client-side search powering the ⌘K palette.
2
+ //
3
+ // A small team's CRM fits in the synced replica, so search runs locally against
4
+ // data already in memory: results appear as you type, with no request per
5
+ // keystroke. Swap in `db.useSearch` (Pylon's FTS) when a workspace outgrows
6
+ // that — the component boundary doesn't change, only this file.
7
+
8
+ export interface SearchItem {
9
+ id: string;
10
+ type: "deal" | "company" | "contact";
11
+ title: string;
12
+ /** Second line: the company for a contact, the domain for a company. */
13
+ subtitle?: string;
14
+ href: string;
15
+ /** Extra text matched but not displayed — an email, say. */
16
+ keywords?: string;
17
+ }
18
+
19
+ export interface Scored extends SearchItem {
20
+ score: number;
21
+ }
22
+
23
+ /**
24
+ * Rank items against a query. Prefix matches beat word-boundary matches, which
25
+ * beat substring matches; ties break on the shorter title, so "Acme" ranks
26
+ * above "Acme Corporation Holdings" for the query "acme".
27
+ */
28
+ export function searchItems(
29
+ items: SearchItem[],
30
+ query: string,
31
+ limit = 8,
32
+ ): SearchItem[] {
33
+ const q = query.trim().toLowerCase();
34
+ if (!q) return items.slice(0, limit);
35
+
36
+ const scored: Scored[] = [];
37
+ for (const item of items) {
38
+ const score = scoreItem(item, q);
39
+ if (score > 0) scored.push({ ...item, score });
40
+ }
41
+ scored.sort(
42
+ (a, b) => b.score - a.score || a.title.length - b.title.length ||
43
+ a.title.localeCompare(b.title),
44
+ );
45
+ return scored.slice(0, limit);
46
+ }
47
+
48
+ function scoreItem(item: SearchItem, q: string): number {
49
+ const title = item.title.toLowerCase();
50
+ if (title.startsWith(q)) return 100;
51
+ if (wordStart(title, q)) return 80;
52
+ if (title.includes(q)) return 60;
53
+
54
+ const haystack = `${item.subtitle ?? ""} ${item.keywords ?? ""}`.toLowerCase();
55
+ if (wordStart(haystack, q)) return 40;
56
+ if (haystack.includes(q)) return 20;
57
+ return 0;
58
+ }
59
+
60
+ function wordStart(haystack: string, needle: string): boolean {
61
+ let from = 0;
62
+ for (;;) {
63
+ const at = haystack.indexOf(needle, from);
64
+ if (at === -1) return false;
65
+ if (at === 0 || /[\s._@/-]/.test(haystack[at - 1])) return true;
66
+ from = at + 1;
67
+ }
68
+ }
69
+
70
+ /** Move through a list with arrow keys, wrapping at both ends. */
71
+ export function moveSelection(
72
+ current: number,
73
+ delta: number,
74
+ length: number,
75
+ ): number {
76
+ if (length === 0) return 0;
77
+ return (current + delta + length) % length;
78
+ }
@@ -0,0 +1,185 @@
1
+ // Demo data for a brand-new helpdesk.
2
+ //
3
+ // An empty inbox shows you nothing about triage, SLA, or the thread view. The
4
+ // first sign-in seeds a realistic queue — including one ticket already past its
5
+ // first-response window, so the breach state is visible rather than theoretical.
6
+ //
7
+ // Pure data + pure shaping; `functions/seedWorkspace.ts` stays a thin wrapper.
8
+
9
+ export interface SeedCustomer {
10
+ key: string;
11
+ name: string;
12
+ email: string;
13
+ company: string;
14
+ }
15
+
16
+ export interface SeedTicket {
17
+ key: string;
18
+ customer: string;
19
+ subject: string;
20
+ status: string;
21
+ priority: string;
22
+ /** Hours since it arrived. */
23
+ age: number;
24
+ /** Hours since the first agent reply; null means nobody has answered. */
25
+ respondedAgo: number | null;
26
+ }
27
+
28
+ export interface SeedMessage {
29
+ ticket: string;
30
+ body: string;
31
+ fromCustomer: boolean;
32
+ internal?: boolean;
33
+ /** Hours ago. */
34
+ age: number;
35
+ }
36
+
37
+ export const SEED_CUSTOMERS: SeedCustomer[] = [
38
+ { key: "dana", name: "Dana Whitfield", email: "dana@northwind.co", company: "Northwind Logistics" },
39
+ { key: "tom", name: "Tom Alvarez", email: "tom@riverbed.coffee", company: "Riverbed Coffee" },
40
+ { key: "priya", name: "Priya Raman", email: "priya@hallmarkdental.com", company: "Hallmark Dental" },
41
+ { key: "ben", name: "Ben Osei", email: "ben@quarry.design", company: "Quarry Design Co" },
42
+ { key: "lena", name: "Lena Fischer", email: "lena@beaconproperty.com", company: "Beacon Property" },
43
+ ];
44
+
45
+ export const SEED_TICKETS: SeedTicket[] = [
46
+ // Urgent, unanswered, 3h old against a 1h target — a live breach on first load.
47
+ {
48
+ key: "export",
49
+ customer: "dana",
50
+ subject: "Export is timing out on large date ranges",
51
+ status: "open",
52
+ priority: "urgent",
53
+ age: 3,
54
+ respondedAgo: null,
55
+ },
56
+ {
57
+ key: "invite",
58
+ customer: "tom",
59
+ subject: "Can't invite a second manager to the account",
60
+ status: "open",
61
+ priority: "high",
62
+ age: 6,
63
+ respondedAgo: 5,
64
+ },
65
+ {
66
+ key: "invoice",
67
+ customer: "priya",
68
+ subject: "Invoice shows last month's plan",
69
+ status: "pending",
70
+ priority: "normal",
71
+ age: 26,
72
+ respondedAgo: 24,
73
+ },
74
+ {
75
+ key: "sso",
76
+ customer: "ben",
77
+ subject: "SSO with Google Workspace — is it supported?",
78
+ status: "open",
79
+ priority: "normal",
80
+ age: 14,
81
+ respondedAgo: null,
82
+ },
83
+ {
84
+ key: "mobile",
85
+ customer: "lena",
86
+ subject: "Mobile layout cuts off the listing photos",
87
+ status: "solved",
88
+ priority: "low",
89
+ age: 120,
90
+ respondedAgo: 110,
91
+ },
92
+ {
93
+ key: "webhook",
94
+ customer: "dana",
95
+ subject: "Webhook retries are firing twice",
96
+ status: "closed",
97
+ priority: "high",
98
+ age: 300,
99
+ respondedAgo: 290,
100
+ },
101
+ ];
102
+
103
+ export const SEED_MESSAGES: SeedMessage[] = [
104
+ {
105
+ ticket: "export",
106
+ body: "Pulling a 90-day export just spins and eventually errors. 30 days is fine. This is blocking our month-end reporting.",
107
+ fromCustomer: true,
108
+ age: 3,
109
+ },
110
+ {
111
+ ticket: "invite",
112
+ body: "I added marcus@northwind.co but he never got the email and doesn't show in the members list.",
113
+ fromCustomer: true,
114
+ age: 6,
115
+ },
116
+ {
117
+ ticket: "invite",
118
+ body: "Thanks Tom — I can see the invite was created but the delivery bounced. Re-sending now and checking the domain's SPF record.",
119
+ fromCustomer: false,
120
+ age: 5,
121
+ },
122
+ {
123
+ ticket: "invite",
124
+ body: "Their MX is misconfigured — flagging for the infra team rather than sending again blind.",
125
+ fromCustomer: false,
126
+ internal: true,
127
+ age: 5,
128
+ },
129
+ {
130
+ ticket: "invoice",
131
+ body: "We upgraded on the 3rd but the invoice still lists the old plan. Can you re-issue?",
132
+ fromCustomer: true,
133
+ age: 26,
134
+ },
135
+ {
136
+ ticket: "invoice",
137
+ body: "Re-issued and emailed — the proration lands on next month's invoice.",
138
+ fromCustomer: false,
139
+ age: 24,
140
+ },
141
+ {
142
+ ticket: "sso",
143
+ body: "We're standardising on Google Workspace this quarter. Does your plan include SAML, or only OAuth sign-in?",
144
+ fromCustomer: true,
145
+ age: 14,
146
+ },
147
+ ];
148
+
149
+ export interface ShapedSeed {
150
+ customers: Array<{ key: string; row: Record<string, unknown> }>;
151
+ tickets: Array<{ key: string; customer: string; row: Record<string, unknown> }>;
152
+ messages: Array<{ ticket: string; row: Record<string, unknown> }>;
153
+ }
154
+
155
+ export function shapeSeed(now: number = Date.now()): ShapedSeed {
156
+ const hoursAgo = (hours: number) => new Date(now - hours * 3_600_000).toISOString();
157
+
158
+ return {
159
+ customers: SEED_CUSTOMERS.map((c) => ({
160
+ key: c.key,
161
+ row: { name: c.name, email: c.email, company: c.company, createdAt: hoursAgo(720) },
162
+ })),
163
+ tickets: SEED_TICKETS.map((t) => ({
164
+ key: t.key,
165
+ customer: t.customer,
166
+ row: {
167
+ subject: t.subject,
168
+ status: t.status,
169
+ priority: t.priority,
170
+ firstRespondedAt: t.respondedAgo === null ? null : hoursAgo(t.respondedAgo),
171
+ createdAt: hoursAgo(t.age),
172
+ updatedAt: hoursAgo(t.respondedAgo ?? t.age),
173
+ },
174
+ })),
175
+ messages: SEED_MESSAGES.map((m) => ({
176
+ ticket: m.ticket,
177
+ row: {
178
+ body: m.body,
179
+ fromCustomer: m.fromCustomer,
180
+ internal: m.internal ?? false,
181
+ createdAt: hoursAgo(m.age),
182
+ },
183
+ })),
184
+ };
185
+ }
@@ -0,0 +1,184 @@
1
+ // The support model: statuses, priority, SLA, and the queue ordering.
2
+ //
3
+ // Pure — no React, no `db`. The rules that decide what an agent sees first are
4
+ // the most important logic in a helpdesk, so they live where they can be tested
5
+ // exhaustively without a server.
6
+
7
+ export interface Status {
8
+ id: string;
9
+ label: string;
10
+ /** Solved and closed leave the queue. */
11
+ terminal?: boolean;
12
+ }
13
+
14
+ export const STATUSES: Status[] = [
15
+ { id: "open", label: "Open" },
16
+ { id: "pending", label: "Pending" },
17
+ { id: "solved", label: "Solved", terminal: true },
18
+ { id: "closed", label: "Closed", terminal: true },
19
+ ];
20
+
21
+ export interface Priority {
22
+ id: string;
23
+ label: string;
24
+ /** Lower sorts first. */
25
+ rank: number;
26
+ /** Hours allowed for a first reply. */
27
+ firstResponseHours: number;
28
+ }
29
+
30
+ export const PRIORITIES: Priority[] = [
31
+ { id: "urgent", label: "Urgent", rank: 0, firstResponseHours: 1 },
32
+ { id: "high", label: "High", rank: 1, firstResponseHours: 4 },
33
+ { id: "normal", label: "Normal", rank: 2, firstResponseHours: 24 },
34
+ { id: "low", label: "Low", rank: 3, firstResponseHours: 72 },
35
+ ];
36
+
37
+ export interface Ticket {
38
+ id: string;
39
+ subject: string;
40
+ status: string;
41
+ priority: string;
42
+ customerId?: string | null;
43
+ assigneeId?: string | null;
44
+ createdAt?: string | null;
45
+ updatedAt?: string | null;
46
+ /** When an agent first replied; null while the customer is still waiting. */
47
+ firstRespondedAt?: string | null;
48
+ }
49
+
50
+ export function statusById(id: string): Status | undefined {
51
+ return STATUSES.find((s) => s.id === id);
52
+ }
53
+
54
+ export function priorityById(id: string): Priority | undefined {
55
+ return PRIORITIES.find((p) => p.id === id);
56
+ }
57
+
58
+ export function isValidStatus(id: string): boolean {
59
+ return STATUSES.some((s) => s.id === id);
60
+ }
61
+
62
+ export function isValidPriority(id: string): boolean {
63
+ return PRIORITIES.some((p) => p.id === id);
64
+ }
65
+
66
+ export function isOpen(ticket: { status: string }): boolean {
67
+ return !statusById(ticket.status)?.terminal;
68
+ }
69
+
70
+ export type SlaState = "met" | "due" | "breached" | "none";
71
+
72
+ /**
73
+ * Where a ticket stands against its first-response target.
74
+ *
75
+ * met — an agent has already replied
76
+ * breached — no reply and the window has passed
77
+ * due — no reply, inside the window
78
+ * none — terminal, or unparseable dates; nothing to chase
79
+ *
80
+ * A solved ticket that was answered late is still `met`: the SLA question is
81
+ * "did we reply", and re-flagging closed work just adds noise to the queue.
82
+ */
83
+ export function slaState(
84
+ ticket: Ticket,
85
+ now: number = Date.now(),
86
+ ): SlaState {
87
+ if (!isOpen(ticket)) return "none";
88
+ if (ticket.firstRespondedAt) return "met";
89
+ const created = Date.parse(ticket.createdAt ?? "");
90
+ if (!Number.isFinite(created)) return "none";
91
+ const hours = priorityById(ticket.priority)?.firstResponseHours;
92
+ if (hours === undefined) return "none";
93
+ return now > created + hours * 3_600_000 ? "breached" : "due";
94
+ }
95
+
96
+ /** Minutes left before the first-response target, negative once breached. */
97
+ export function minutesToBreach(
98
+ ticket: Ticket,
99
+ now: number = Date.now(),
100
+ ): number | null {
101
+ if (!isOpen(ticket) || ticket.firstRespondedAt) return null;
102
+ const created = Date.parse(ticket.createdAt ?? "");
103
+ const hours = priorityById(ticket.priority)?.firstResponseHours;
104
+ if (!Number.isFinite(created) || hours === undefined) return null;
105
+ return Math.round((created + hours * 3_600_000 - now) / 60_000);
106
+ }
107
+
108
+ /**
109
+ * Queue order: breached first, then by priority, then oldest first.
110
+ *
111
+ * Oldest-first within a priority is deliberate — sorting newest-first is how a
112
+ * ticket sits at the bottom of a busy queue for a week.
113
+ */
114
+ export function queueOrder(tickets: Ticket[], now: number = Date.now()): Ticket[] {
115
+ return [...tickets].sort((a, b) => {
116
+ const aBreached = slaState(a, now) === "breached" ? 0 : 1;
117
+ const bBreached = slaState(b, now) === "breached" ? 0 : 1;
118
+ if (aBreached !== bBreached) return aBreached - bBreached;
119
+
120
+ const aRank = priorityById(a.priority)?.rank ?? 99;
121
+ const bRank = priorityById(b.priority)?.rank ?? 99;
122
+ if (aRank !== bRank) return aRank - bRank;
123
+
124
+ return (a.createdAt ?? "").localeCompare(b.createdAt ?? "");
125
+ });
126
+ }
127
+
128
+ export interface QueueCounts {
129
+ open: number;
130
+ pending: number;
131
+ solved: number;
132
+ closed: number;
133
+ unassigned: number;
134
+ breached: number;
135
+ }
136
+
137
+ export function counts(tickets: Ticket[], now: number = Date.now()): QueueCounts {
138
+ const result: QueueCounts = {
139
+ open: 0,
140
+ pending: 0,
141
+ solved: 0,
142
+ closed: 0,
143
+ unassigned: 0,
144
+ breached: 0,
145
+ };
146
+ for (const ticket of tickets) {
147
+ if (ticket.status in result) {
148
+ result[ticket.status as keyof QueueCounts] += 1;
149
+ }
150
+ if (isOpen(ticket) && !ticket.assigneeId) result.unassigned += 1;
151
+ if (slaState(ticket, now) === "breached") result.breached += 1;
152
+ }
153
+ return result;
154
+ }
155
+
156
+ export interface Filter {
157
+ status?: string;
158
+ priority?: string;
159
+ assigneeId?: string;
160
+ /** "unassigned" narrows to tickets with no agent. */
161
+ unassigned?: boolean;
162
+ }
163
+
164
+ export function applyFilter(tickets: Ticket[], filter: Filter): Ticket[] {
165
+ return tickets.filter((ticket) => {
166
+ if (filter.status && ticket.status !== filter.status) return false;
167
+ if (filter.priority && ticket.priority !== filter.priority) return false;
168
+ if (filter.assigneeId && ticket.assigneeId !== filter.assigneeId) return false;
169
+ if (filter.unassigned && ticket.assigneeId) return false;
170
+ return true;
171
+ });
172
+ }
173
+
174
+ /** "#1042" — short, stable, and greppable in an email thread. */
175
+ export function ticketNumber(id: string, createdAt?: string | null): string {
176
+ // Derived from the id so it needs no counter and can't collide across
177
+ // concurrent inserts; the created date is only a tiebreaker for display.
178
+ let hash = 0;
179
+ const seed = `${id}${createdAt ?? ""}`;
180
+ for (let i = 0; i < seed.length; i += 1) {
181
+ hash = (hash * 31 + seed.charCodeAt(i)) | 0;
182
+ }
183
+ return `#${(Math.abs(hash) % 9000) + 1000}`;
184
+ }
@@ -0,0 +1,10 @@
1
+ import { type ClassValue, clsx } from "clsx";
2
+ import { twMerge } from "tailwind-merge";
3
+
4
+ // `cn` — the shadcn class merger. clsx resolves conditional/array class
5
+ // inputs; tailwind-merge then dedupes conflicting Tailwind utilities so
6
+ // the last one wins (e.g. `cn("px-2", "px-4")` → "px-4"). Every shadcn
7
+ // component routes its className through this.
8
+ export function cn(...inputs: ClassValue[]) {
9
+ return twMerge(clsx(inputs));
10
+ }