@rubytech/create-sitedesk-code 0.1.562 → 0.1.564

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 (200) hide show
  1. package/package.json +1 -1
  2. package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +38 -11
  3. package/payload/platform/plugins/admin/skills/whats-new/SKILL.md +12 -0
  4. package/payload/platform/plugins/cloudflare/bin/portal-enrol.mjs +19 -5
  5. package/payload/platform/plugins/cloudflare/mcp/__tests__/authorize.test.ts +6 -0
  6. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-enrol.test.ts +77 -6
  7. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-session-grant.test.ts +39 -0
  8. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-visit-status-write.test.ts +294 -0
  9. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-visits-scope.test.ts +112 -0
  10. package/payload/platform/plugins/cloudflare/skills/data-portal/SKILL.md +38 -0
  11. package/payload/platform/plugins/cloudflare/skills/data-portal/schema.sql +112 -0
  12. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/session.ts +31 -4
  13. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/visit-status.ts +193 -0
  14. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/visits.ts +93 -0
  15. package/payload/platform/plugins/docs/references/deployment.md +14 -8
  16. package/payload/platform/plugins/docs/references/platform.md +4 -2
  17. package/payload/platform/plugins/docs/references/telegram-guide.md +19 -0
  18. package/payload/platform/plugins/memory/references/schema-construction.md +1 -1
  19. package/payload/platform/templates/agents/public/IDENTITY.md +3 -3
  20. package/payload/premium-plugins/sitedesk/agents/sitedesk--payroll-clerk.md +2 -2
  21. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/skills/timesheet/SKILL.md +5 -5
  22. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/skills/timesheet/references/checkin-contract.md +40 -22
  23. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/skills/timesheet/references/roll-up.md +20 -7
  24. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/skills/timesheet/references/setup.md +7 -3
  25. package/payload/server/{chunk-G6ULACHG.js → chunk-3ABITJWB.js} +8 -4
  26. package/payload/server/{manager-2VEJKVEO.js → manager-N2RVJN7T.js} +1 -1
  27. package/payload/server/maxy-edge.js +302 -33
  28. package/payload/server/public/activity.html +5 -5
  29. package/payload/server/public/agents.html +4 -4
  30. package/payload/server/public/assets/{AdminLoginScreens-DLogIsJT.js → AdminLoginScreens-5gI137xF.js} +1 -1
  31. package/payload/server/public/assets/AdminLoginScreens-5gI137xF.js.br +0 -0
  32. package/payload/server/public/assets/AdminLoginScreens-5gI137xF.js.gz +0 -0
  33. package/payload/server/public/assets/AdminShell-Bfapavlp.js +3 -0
  34. package/payload/server/public/assets/AdminShell-Bfapavlp.js.br +0 -0
  35. package/payload/server/public/assets/AdminShell-Bfapavlp.js.gz +0 -0
  36. package/payload/server/public/assets/{activity-AVJSFESV.js → activity-DnbsmUgn.js} +1 -1
  37. package/payload/server/public/assets/activity-DnbsmUgn.js.br +0 -0
  38. package/payload/server/public/assets/activity-DnbsmUgn.js.gz +0 -0
  39. package/payload/server/public/assets/admin-BXIv0_oj.js +1 -0
  40. package/payload/server/public/assets/admin-BXIv0_oj.js.br +0 -0
  41. package/payload/server/public/assets/admin-BXIv0_oj.js.gz +0 -0
  42. package/payload/server/public/assets/{agents-CoWLZMf7.js → agents-Dl2wOcpj.js} +1 -1
  43. package/payload/server/public/assets/agents-Dl2wOcpj.js.br +0 -0
  44. package/payload/server/public/assets/agents-Dl2wOcpj.js.gz +0 -0
  45. package/payload/server/public/assets/{browser-C6K66_fw.js → browser-D-Uc9ewO.js} +1 -1
  46. package/payload/server/public/assets/browser-D-Uc9ewO.js.br +0 -0
  47. package/payload/server/public/assets/browser-D-Uc9ewO.js.gz +0 -0
  48. package/payload/server/public/assets/{calendar-Dnw_cP4b.js → calendar-Cu3O6Bay.js} +1 -1
  49. package/payload/server/public/assets/calendar-Cu3O6Bay.js.br +0 -0
  50. package/payload/server/public/assets/calendar-Cu3O6Bay.js.gz +0 -0
  51. package/payload/server/public/assets/chat-Bso94e-f.js +1 -0
  52. package/payload/server/public/assets/chat-Bso94e-f.js.br +0 -0
  53. package/payload/server/public/assets/chat-Bso94e-f.js.gz +0 -0
  54. package/payload/server/public/assets/chevron-left-CDwfR9V-.js +1 -0
  55. package/payload/server/public/assets/chevron-left-CDwfR9V-.js.br +0 -0
  56. package/payload/server/public/assets/clock-DDVrNLt9.js +1 -0
  57. package/payload/server/public/assets/clock-DDVrNLt9.js.br +0 -0
  58. package/payload/server/public/assets/clock-DDVrNLt9.js.gz +0 -0
  59. package/payload/server/public/assets/{copy-kuNM0Etw.js → copy-DPBVV8KZ.js} +1 -1
  60. package/payload/server/public/assets/copy-DPBVV8KZ.js.br +0 -0
  61. package/payload/server/public/assets/copy-DPBVV8KZ.js.gz +0 -0
  62. package/payload/server/public/assets/data-Dei8n2EX.js +1 -0
  63. package/payload/server/public/assets/data-Dei8n2EX.js.br +0 -0
  64. package/payload/server/public/assets/data-Dei8n2EX.js.gz +0 -0
  65. package/payload/server/public/assets/{file-text-BWkobrcx.js → file-text-Dn82O6ke.js} +1 -1
  66. package/payload/server/public/assets/file-text-Dn82O6ke.js.br +0 -0
  67. package/payload/server/public/assets/file-text-Dn82O6ke.js.gz +0 -0
  68. package/payload/server/public/assets/{graph-BGjTL5Vz.js → graph-D2J7a7Ll.js} +1 -1
  69. package/payload/server/public/assets/graph-D2J7a7Ll.js.br +0 -0
  70. package/payload/server/public/assets/graph-D2J7a7Ll.js.gz +0 -0
  71. package/payload/server/public/assets/{graph-labels-Dvc5Kw_r.js → graph-labels-D3gkY2IH.js} +1 -1
  72. package/payload/server/public/assets/graph-labels-D3gkY2IH.js.br +0 -0
  73. package/payload/server/public/assets/graph-labels-D3gkY2IH.js.gz +0 -0
  74. package/payload/server/public/assets/{operator-CzBGv_he.js → operator-Bz_yj1im.js} +1 -1
  75. package/payload/server/public/assets/operator-Bz_yj1im.js.br +0 -0
  76. package/payload/server/public/assets/operator-Bz_yj1im.js.gz +0 -0
  77. package/payload/server/public/assets/page-D6u2xYh7.js +1 -0
  78. package/payload/server/public/assets/page-D6u2xYh7.js.br +0 -0
  79. package/payload/server/public/assets/page-D6u2xYh7.js.gz +0 -0
  80. package/payload/server/public/assets/{page-BIY4Ku8Q.js → page-mH4nyQ75.js} +2 -2
  81. package/payload/server/public/assets/page-mH4nyQ75.js.br +0 -0
  82. package/payload/server/public/assets/page-mH4nyQ75.js.gz +0 -0
  83. package/payload/server/public/assets/play-ByuChQJJ.js +1 -0
  84. package/payload/server/public/assets/play-ByuChQJJ.js.br +0 -0
  85. package/payload/server/public/assets/play-ByuChQJJ.js.gz +0 -0
  86. package/payload/server/public/assets/{public-pwDSvhBv.js → public-CtoYVjPR.js} +1 -1
  87. package/payload/server/public/assets/public-CtoYVjPR.js.br +0 -0
  88. package/payload/server/public/assets/public-CtoYVjPR.js.gz +0 -0
  89. package/payload/server/public/assets/{rotate-ccw-B6s7tkEt.js → rotate-ccw-BXoIghE3.js} +1 -1
  90. package/payload/server/public/assets/rotate-ccw-BXoIghE3.js.br +0 -0
  91. package/payload/server/public/assets/rotate-ccw-BXoIghE3.js.gz +0 -0
  92. package/payload/server/public/assets/{routines-CFlyRdYg.js → routines-DXleYpDv.js} +1 -1
  93. package/payload/server/public/assets/routines-DXleYpDv.js.br +0 -0
  94. package/payload/server/public/assets/routines-DXleYpDv.js.gz +0 -0
  95. package/payload/server/public/assets/{skills-C45kfL_Q.js → skills-CMl38MKH.js} +1 -1
  96. package/payload/server/public/assets/skills-CMl38MKH.js.br +0 -0
  97. package/payload/server/public/assets/skills-CMl38MKH.js.gz +0 -0
  98. package/payload/server/public/assets/{tasks-BwoCTCu5.js → tasks-Cshaf0Md.js} +1 -1
  99. package/payload/server/public/assets/tasks-Cshaf0Md.js.br +0 -0
  100. package/payload/server/public/assets/tasks-Cshaf0Md.js.gz +0 -0
  101. package/payload/server/public/assets/{triangle-alert-B3bthSzx.js → triangle-alert-CS-NXr_9.js} +1 -1
  102. package/payload/server/public/assets/triangle-alert-CS-NXr_9.js.br +3 -0
  103. package/payload/server/public/assets/triangle-alert-CS-NXr_9.js.gz +0 -0
  104. package/payload/server/public/assets/{useCopyFeedback-BzJAkFEb.js → useCopyFeedback-pKLfQJNG.js} +1 -1
  105. package/payload/server/public/assets/useCopyFeedback-pKLfQJNG.js.br +0 -0
  106. package/payload/server/public/assets/useCopyFeedback-pKLfQJNG.js.gz +0 -0
  107. package/payload/server/public/assets/useMediaQuery-BHFn0-S9.css +1 -0
  108. package/payload/server/public/assets/useMediaQuery-BHFn0-S9.css.br +0 -0
  109. package/payload/server/public/assets/useMediaQuery-BHFn0-S9.css.gz +0 -0
  110. package/payload/server/public/assets/useMediaQuery-oDCasnxD.js +14 -0
  111. package/payload/server/public/assets/useMediaQuery-oDCasnxD.js.br +0 -0
  112. package/payload/server/public/assets/useMediaQuery-oDCasnxD.js.gz +0 -0
  113. package/payload/server/public/assets/useVoiceRecorder-EgO1GmIg.js +2 -0
  114. package/payload/server/public/assets/useVoiceRecorder-EgO1GmIg.js.br +0 -0
  115. package/payload/server/public/assets/useVoiceRecorder-EgO1GmIg.js.gz +0 -0
  116. package/payload/server/public/assets/{wrench-Bqux9jaw.js → wrench-czES9Rdy.js} +1 -1
  117. package/payload/server/public/assets/wrench-czES9Rdy.js.br +0 -0
  118. package/payload/server/public/assets/wrench-czES9Rdy.js.gz +0 -0
  119. package/payload/server/public/browser.html +4 -4
  120. package/payload/server/public/calendar.html +7 -7
  121. package/payload/server/public/chat.html +13 -13
  122. package/payload/server/public/data.html +11 -11
  123. package/payload/server/public/graph.html +9 -9
  124. package/payload/server/public/index.html +13 -13
  125. package/payload/server/public/operator.html +14 -14
  126. package/payload/server/public/public.html +13 -13
  127. package/payload/server/public/routines.html +7 -7
  128. package/payload/server/public/skills.html +5 -5
  129. package/payload/server/public/tasks.html +6 -6
  130. package/payload/server/server.js +2946 -1683
  131. package/payload/server/public/assets/AdminLoginScreens-DLogIsJT.js.br +0 -0
  132. package/payload/server/public/assets/AdminLoginScreens-DLogIsJT.js.gz +0 -0
  133. package/payload/server/public/assets/AdminShell-B67tPTyE.js +0 -2
  134. package/payload/server/public/assets/AdminShell-B67tPTyE.js.br +0 -0
  135. package/payload/server/public/assets/AdminShell-B67tPTyE.js.gz +0 -0
  136. package/payload/server/public/assets/activity-AVJSFESV.js.br +0 -0
  137. package/payload/server/public/assets/activity-AVJSFESV.js.gz +0 -0
  138. package/payload/server/public/assets/admin-eFgbld42.js +0 -1
  139. package/payload/server/public/assets/admin-eFgbld42.js.br +0 -0
  140. package/payload/server/public/assets/admin-eFgbld42.js.gz +0 -0
  141. package/payload/server/public/assets/agents-CoWLZMf7.js.br +0 -0
  142. package/payload/server/public/assets/agents-CoWLZMf7.js.gz +0 -0
  143. package/payload/server/public/assets/browser-C6K66_fw.js.br +0 -0
  144. package/payload/server/public/assets/browser-C6K66_fw.js.gz +0 -0
  145. package/payload/server/public/assets/calendar-Dnw_cP4b.js.br +0 -0
  146. package/payload/server/public/assets/calendar-Dnw_cP4b.js.gz +0 -0
  147. package/payload/server/public/assets/chat-Cm0fkpg1.js +0 -1
  148. package/payload/server/public/assets/chat-Cm0fkpg1.js.br +0 -0
  149. package/payload/server/public/assets/chat-Cm0fkpg1.js.gz +0 -0
  150. package/payload/server/public/assets/chevron-left-CjRH-BtF.js +0 -1
  151. package/payload/server/public/assets/chevron-left-CjRH-BtF.js.br +0 -0
  152. package/payload/server/public/assets/clock-wbYEt1kM.js +0 -1
  153. package/payload/server/public/assets/clock-wbYEt1kM.js.br +0 -0
  154. package/payload/server/public/assets/clock-wbYEt1kM.js.gz +0 -0
  155. package/payload/server/public/assets/copy-kuNM0Etw.js.br +0 -0
  156. package/payload/server/public/assets/copy-kuNM0Etw.js.gz +0 -0
  157. package/payload/server/public/assets/data-c47T2NCd.js +0 -1
  158. package/payload/server/public/assets/data-c47T2NCd.js.br +0 -1
  159. package/payload/server/public/assets/data-c47T2NCd.js.gz +0 -0
  160. package/payload/server/public/assets/file-text-BWkobrcx.js.br +0 -0
  161. package/payload/server/public/assets/file-text-BWkobrcx.js.gz +0 -0
  162. package/payload/server/public/assets/graph-BGjTL5Vz.js.br +0 -0
  163. package/payload/server/public/assets/graph-BGjTL5Vz.js.gz +0 -0
  164. package/payload/server/public/assets/graph-labels-Dvc5Kw_r.js.br +0 -0
  165. package/payload/server/public/assets/graph-labels-Dvc5Kw_r.js.gz +0 -0
  166. package/payload/server/public/assets/operator-CzBGv_he.js.br +0 -0
  167. package/payload/server/public/assets/operator-CzBGv_he.js.gz +0 -0
  168. package/payload/server/public/assets/page-BIY4Ku8Q.js.br +0 -0
  169. package/payload/server/public/assets/page-BIY4Ku8Q.js.gz +0 -0
  170. package/payload/server/public/assets/page-DaySqamH.js +0 -1
  171. package/payload/server/public/assets/page-DaySqamH.js.br +0 -0
  172. package/payload/server/public/assets/page-DaySqamH.js.gz +0 -0
  173. package/payload/server/public/assets/play-C8RA52_i.js +0 -1
  174. package/payload/server/public/assets/play-C8RA52_i.js.br +0 -0
  175. package/payload/server/public/assets/play-C8RA52_i.js.gz +0 -0
  176. package/payload/server/public/assets/public-pwDSvhBv.js.br +0 -0
  177. package/payload/server/public/assets/public-pwDSvhBv.js.gz +0 -0
  178. package/payload/server/public/assets/rotate-ccw-B6s7tkEt.js.br +0 -0
  179. package/payload/server/public/assets/rotate-ccw-B6s7tkEt.js.gz +0 -0
  180. package/payload/server/public/assets/routines-CFlyRdYg.js.br +0 -0
  181. package/payload/server/public/assets/routines-CFlyRdYg.js.gz +0 -0
  182. package/payload/server/public/assets/skills-C45kfL_Q.js.br +0 -0
  183. package/payload/server/public/assets/skills-C45kfL_Q.js.gz +0 -0
  184. package/payload/server/public/assets/tasks-BwoCTCu5.js.br +0 -0
  185. package/payload/server/public/assets/tasks-BwoCTCu5.js.gz +0 -0
  186. package/payload/server/public/assets/triangle-alert-B3bthSzx.js.br +0 -0
  187. package/payload/server/public/assets/triangle-alert-B3bthSzx.js.gz +0 -0
  188. package/payload/server/public/assets/useCopyFeedback-BzJAkFEb.js.br +0 -0
  189. package/payload/server/public/assets/useCopyFeedback-BzJAkFEb.js.gz +0 -0
  190. package/payload/server/public/assets/useMediaQuery-DJ6XeNN7.js +0 -14
  191. package/payload/server/public/assets/useMediaQuery-DJ6XeNN7.js.br +0 -0
  192. package/payload/server/public/assets/useMediaQuery-DJ6XeNN7.js.gz +0 -0
  193. package/payload/server/public/assets/useMediaQuery-DW_xRrMl.css +0 -1
  194. package/payload/server/public/assets/useMediaQuery-DW_xRrMl.css.br +0 -0
  195. package/payload/server/public/assets/useMediaQuery-DW_xRrMl.css.gz +0 -0
  196. package/payload/server/public/assets/useVoiceRecorder-_qI_BAOo.js +0 -2
  197. package/payload/server/public/assets/useVoiceRecorder-_qI_BAOo.js.br +0 -0
  198. package/payload/server/public/assets/useVoiceRecorder-_qI_BAOo.js.gz +0 -0
  199. package/payload/server/public/assets/wrench-Bqux9jaw.js.br +0 -0
  200. package/payload/server/public/assets/wrench-Bqux9jaw.js.gz +0 -0
@@ -0,0 +1,193 @@
1
+ import type { Handler, Logger, PortalEnv } from './_lib/types'
2
+ import { readSessionCookie, resolveSession } from './_lib/session'
3
+ import { q } from './_lib/log'
4
+
5
+ /**
6
+ * The longest status a person may set.
7
+ *
8
+ * The value reaches `:Visit.status` through the device-side pull, so it is
9
+ * graph content arriving from the open web. It is refused at the door rather
10
+ * than truncated: a truncated status is a different status, silently, and the
11
+ * person who set it is never told.
12
+ */
13
+ export const MAX_STATUS_LENGTH = 64
14
+
15
+ /**
16
+ * The longest work note a person may attach to a day.
17
+ *
18
+ * The note reaches `:JobCost.description` through the device-side pull, so it
19
+ * is graph content arriving from the open web and gets the same treatment the
20
+ * status gets: refused at the door, never truncated. The size is chosen for a
21
+ * description cell on the A4 timesheet row, not derived from anything.
22
+ */
23
+ export const MAX_NOTE_LENGTH = 500
24
+
25
+ /**
26
+ * The only date shape a claim may carry.
27
+ *
28
+ * The date becomes part of the cost key the pull merges on, so a free-form
29
+ * string would produce a key nobody can reproduce. A COPY of this check lives
30
+ * in `platform/ui/server/portal-visits-pull.ts` as the invariant at the writer;
31
+ * the two cannot share a constant because this file ships to Cloudflare Pages
32
+ * and cannot import from the platform tree.
33
+ */
34
+ export const WORK_DATE_RE = /^\d{4}-\d{2}-\d{2}$/
35
+
36
+ /**
37
+ * Record a status a person set against one of their visits.
38
+ *
39
+ * THE GATE IS THE SELECT, not a check beside it. The insert is
40
+ * `INSERT ... SELECT` over the same `visits` join, under the same two filters
41
+ * the read applies — the account from the baked-in var, and the owner when and
42
+ * only when the person's scope is `own`. A visit the person may not see admits
43
+ * no row, so `meta.changes` is 0 and the handler answers 404. There is no
44
+ * separate authorisation step that a later edit could reorder past the write.
45
+ *
46
+ * This ships in the platform template for the same reason the read does, and
47
+ * more sharply. A read leak shows someone a working day that is not theirs; a
48
+ * write leak lets them change it. Neither gate is something to re-implement per
49
+ * client.
50
+ *
51
+ * `ownerId` is stamped from the resolved session and never from the body. A
52
+ * request that names one is ignored rather than refused, because the field
53
+ * carries no authority here — the session already decided who is writing.
54
+ */
55
+ export async function processVisitStatus(
56
+ body: unknown,
57
+ sessionId: string,
58
+ env: PortalEnv,
59
+ log: Logger,
60
+ nowMs: number,
61
+ ): Promise<Handler> {
62
+ const session = await resolveSession(env.DB, sessionId, env.PORTAL_ACCOUNT_ID, nowMs)
63
+ if (!session) {
64
+ log('[data-portal] op=visit-status owner=none result=denied')
65
+ return { status: 401, payload: { ok: false, error: 'denied' } }
66
+ }
67
+
68
+ const b = (body ?? {}) as Record<string, unknown>
69
+ const visitId = typeof b.visitId === 'string' ? b.visitId : ''
70
+ const status = typeof b.status === 'string' ? b.status : ''
71
+ if (!visitId || !status) {
72
+ log(`[data-portal] op=visit-status owner=${q(session.ownerId)} result=bad-request`)
73
+ return { status: 400, payload: { ok: false, error: 'visitId and status are required' } }
74
+ }
75
+ if (status.length > MAX_STATUS_LENGTH) {
76
+ log(`[data-portal] op=visit-status owner=${q(session.ownerId)} result=status-too-long`)
77
+ return { status: 400, payload: { ok: false, error: 'status too long' } }
78
+ }
79
+
80
+ // The working day the edit carries, if any. Absent means a status-only edit,
81
+ // which is the common case and stays a zero-claim write.
82
+ const dayHundredths = b.dayHundredths === undefined ? 0 : b.dayHundredths
83
+ if (
84
+ typeof dayHundredths !== 'number' ||
85
+ !Number.isInteger(dayHundredths) ||
86
+ dayHundredths < 0 ||
87
+ dayHundredths > 100
88
+ ) {
89
+ log(`[data-portal] op=visit-status owner=${q(session.ownerId)} result=bad-day`)
90
+ return { status: 400, payload: { ok: false, error: 'dayHundredths must be an integer 0..100' } }
91
+ }
92
+ const workDate = typeof b.workDate === 'string' ? b.workDate : ''
93
+ const workNote = typeof b.workNote === 'string' ? b.workNote : ''
94
+ // A claim the roll-up could not date is refused now rather than queued. The
95
+ // pull holds the same invariant, so a row reaching the table another way is
96
+ // still never written under a guessed date.
97
+ if (dayHundredths > 0 && !WORK_DATE_RE.test(workDate)) {
98
+ log(`[data-portal] op=visit-status owner=${q(session.ownerId)} result=undated-claim`)
99
+ return {
100
+ status: 400,
101
+ payload: { ok: false, error: 'a day claim requires workDate as YYYY-MM-DD' },
102
+ }
103
+ }
104
+ if (workNote.length > MAX_NOTE_LENGTH) {
105
+ log(`[data-portal] op=visit-status owner=${q(session.ownerId)} result=note-too-long`)
106
+ return { status: 400, payload: { ok: false, error: 'note too long' } }
107
+ }
108
+
109
+ const scoped = session.recordScope === 'own'
110
+ const sql =
111
+ `INSERT INTO visit_status (accountId, ownerId, status, statusAt, dayHundredths, workDate, workNote, claimed, visitId)
112
+ SELECT ?, ?, ?, ?, ?, ?, ?, 0, v.visitId
113
+ FROM visits v
114
+ JOIN visits_state s
115
+ ON s.accountId = v.accountId AND s.currentGeneration = v.generation
116
+ WHERE v.accountId = ?` +
117
+ (scoped ? ' AND v.ownerId = ?' : '') +
118
+ ' AND v.visitId = ?'
119
+ const statusAt = new Date(nowMs).toISOString()
120
+ const stmt = env.DB.prepare(sql)
121
+ const bound = scoped
122
+ ? stmt.bind(
123
+ env.PORTAL_ACCOUNT_ID,
124
+ session.ownerId,
125
+ status,
126
+ statusAt,
127
+ dayHundredths,
128
+ workDate,
129
+ workNote,
130
+ env.PORTAL_ACCOUNT_ID,
131
+ session.ownerId,
132
+ visitId,
133
+ )
134
+ : stmt.bind(
135
+ env.PORTAL_ACCOUNT_ID,
136
+ session.ownerId,
137
+ status,
138
+ statusAt,
139
+ dayHundredths,
140
+ workDate,
141
+ workNote,
142
+ env.PORTAL_ACCOUNT_ID,
143
+ visitId,
144
+ )
145
+ const result = await bound.run()
146
+ const changes = result.meta?.changes ?? 0
147
+
148
+ if (changes === 0) {
149
+ // One answer for "no such visit" and "not yours", deliberately. The two are
150
+ // the same fact to a caller who may not see it, and distinguishing them
151
+ // would let anyone enumerate the account's visit ids.
152
+ log(
153
+ `[data-portal] op=visit-status owner=${q(session.ownerId)} account=${q(session.accountId)} ` +
154
+ `scope=${session.recordScope} result=not-found`,
155
+ )
156
+ return { status: 404, payload: { ok: false, error: 'not found' } }
157
+ }
158
+
159
+ // Counts, the scope, whether a day was claimed, and the outcome. Never the
160
+ // status text, the date or the note: they describe what a real person was
161
+ // doing and when.
162
+ log(
163
+ `[data-portal] op=visit-status owner=${q(session.ownerId)} account=${q(session.accountId)} ` +
164
+ `scope=${session.recordScope} labour=${dayHundredths > 0} queued=${changes} result=ok`,
165
+ )
166
+ return { status: 200, payload: { ok: true, queued: changes } }
167
+ }
168
+
169
+ interface PagesContext {
170
+ request: Request
171
+ env: PortalEnv
172
+ }
173
+
174
+ export async function onRequestPost(context: PagesContext): Promise<Response> {
175
+ const sessionId = readSessionCookie(context.request.headers.get('cookie')) ?? ''
176
+ let body: unknown = null
177
+ try {
178
+ body = await context.request.json()
179
+ } catch {
180
+ // A malformed body is a bad request, not a crash. processVisitStatus
181
+ // resolves the session first, so an unauthenticated caller still gets 401
182
+ // rather than learning that the body parse is what failed.
183
+ body = null
184
+ }
185
+ const { status, payload } = await processVisitStatus(
186
+ body,
187
+ sessionId,
188
+ context.env,
189
+ (line) => console.log(line),
190
+ Date.now(),
191
+ )
192
+ return Response.json(payload, { status })
193
+ }
@@ -0,0 +1,93 @@
1
+ import type { Handler, Logger, PortalEnv } from './_lib/types'
2
+ import { readSessionCookie, resolveSession } from './_lib/session'
3
+ import { q } from './_lib/log'
4
+
5
+ /**
6
+ * One published visit, as an account's own page renders it.
7
+ *
8
+ * The ontology's own fields (`schema-construction.md`) and the portal person the
9
+ * visit is assigned to, and nothing else. A job sheet's contents are the
10
+ * account's to design, and they are files, which the Task 1910 exchange already
11
+ * carries — so nothing of theirs belongs in this shape.
12
+ */
13
+ export interface VisitRow {
14
+ visitId: string
15
+ ownerId: string
16
+ jobId: string
17
+ status: string
18
+ startDate: string
19
+ purpose: string
20
+ }
21
+
22
+ /**
23
+ * The visits a signed-in person may read.
24
+ *
25
+ * TWO FILTERS, BOTH SERVER-SIDE, NEITHER REACHABLE FROM THE REQUEST. The
26
+ * account comes from the baked-in var for the reason `types.ts` gives at
27
+ * PORTAL_ACCOUNT_ID; the owner filter is applied when, and only when, the
28
+ * person's scope is `own`, read off the `people` join. No request parameter
29
+ * names either one, so there is no widening a caller can ask for.
30
+ *
31
+ * THIS FUNCTION IS THE ONLY READER OF `recordScope`, and that is deliberate. A
32
+ * scope column with no reader would be an unused column rather than a boundary.
33
+ * It ships here rather than being left for each account to write in its own copy
34
+ * of this tree, because a gate written once per client is a gate got wrong once
35
+ * per client — and this is the boundary that lets the surface sit on the open
36
+ * web.
37
+ */
38
+ export async function processVisits(
39
+ sessionId: string,
40
+ env: PortalEnv,
41
+ log: Logger,
42
+ nowMs: number,
43
+ ): Promise<Handler> {
44
+ const session = await resolveSession(env.DB, sessionId, env.PORTAL_ACCOUNT_ID, nowMs)
45
+ if (!session) {
46
+ log('[data-portal] op=visits owner=none result=denied')
47
+ return { status: 401, payload: { ok: false, error: 'denied' } }
48
+ }
49
+
50
+ const scoped = session.recordScope === 'own'
51
+ // Read through the generation pointer, so a listing that races a push sees one
52
+ // generation whole rather than a half-built one — the same join `files.ts`
53
+ // makes against directory_state, for the same reason.
54
+ const sql =
55
+ `SELECT v.visitId AS visitId, v.ownerId AS ownerId, v.jobId AS jobId,
56
+ v.status AS status, v.startDate AS startDate, v.purpose AS purpose
57
+ FROM visits v
58
+ JOIN visits_state s
59
+ ON s.accountId = v.accountId AND s.currentGeneration = v.generation
60
+ WHERE v.accountId = ?` +
61
+ (scoped ? ' AND v.ownerId = ?' : '') +
62
+ ' ORDER BY v.startDate ASC, v.visitId ASC'
63
+ const stmt = env.DB.prepare(sql)
64
+ const bound = scoped
65
+ ? stmt.bind(env.PORTAL_ACCOUNT_ID, session.ownerId)
66
+ : stmt.bind(env.PORTAL_ACCOUNT_ID)
67
+ const rows = await bound.all<VisitRow>()
68
+ const visits = rows.results ?? []
69
+
70
+ // Counts and the scope, never a visitId or a purpose: those are a real
71
+ // person's job, and a Pages log is not where they belong.
72
+ log(
73
+ `[data-portal] op=visits owner=${q(session.ownerId)} account=${q(session.accountId)} ` +
74
+ `scope=${session.recordScope} visits=${visits.length} result=ok`,
75
+ )
76
+ return { status: 200, payload: { ok: true, visits } }
77
+ }
78
+
79
+ interface PagesContext {
80
+ request: Request
81
+ env: PortalEnv
82
+ }
83
+
84
+ export async function onRequestGet(context: PagesContext): Promise<Response> {
85
+ const sessionId = readSessionCookie(context.request.headers.get('cookie')) ?? ''
86
+ const { status, payload } = await processVisits(
87
+ sessionId,
88
+ context.env,
89
+ (line) => console.log(line),
90
+ Date.now(),
91
+ )
92
+ return Response.json(payload, { status })
93
+ }
@@ -201,11 +201,11 @@ The logs will show which service failed to start and why. Common causes:
201
201
  Each installed brand runs two per-brand `--user` systemd units (earlier platform fixes + — unit filenames are prefixed with the brand's `hostname` so two brands on the same device never share a unit file):
202
202
 
203
203
  - `{hostname}.service` — the admin + public HTTP server on `127.0.0.1:19201` (public port + 1). Restarted by the upgrade flow; short downtime is expected during steps 8→11 of an upgrade. An earlier fix: the unit carries two port env vars — `PORT=<public>` (canonical public port, read by the upgrade detector) and `MAXY_UI_INTERNAL_PORT=<public+1>` (the port maxy-ui actually binds).
204
- - `{hostname}-edge.service` — the always-on public listener on the configured port (default 19200). Reverse-proxies HTTP to the main brand service and handles `/websockify` (VNC) WebSocket upgrades locally. An earlier fix: also hosts `/api/admin/actions/*` and `/api/admin/version*` — the Software Update modal's own routes — so the log stream survives the brand service's restart window. Does NOT restart during an upgrade the browser WebSocket stays connected by construction. The operator views the device's Chromium in the admin UI via the standalone `/browser` page (peer to `/graph` and `/data`) and on the Claude sign-in screen; both render CDP screencast frames over `/cdp-screencast`, not the framebuffer. Virtual-mode Chromium is headless, so nothing renders onto the X display and the `/websockify` upgrade has no remaining consumer; deleting that stack is separate work.
204
+ - `{hostname}-edge.service` — the always-on public listener on the configured port (default 19200). Reverse-proxies HTTP to the main brand service and handles `/websockify` (VNC) WebSocket upgrades locally. An earlier fix: also hosts `/api/admin/actions/*` and `/api/admin/version*` — the Software Update modal's own routes — so they survive the brand service's restart window. It is restarted by the upgrade flow too (`packages/create-maxy-code/src/index.ts:5143`, after the brand service is stopped and before it is restarted), so the update stream drops near the end of every run and resumes by byte offset. It is not a connection that survives; the log file is what survives, and the browser's `EventSource` replays `Last-Event-ID` to pick the tail back up. The operator views the device's Chromium in the admin UI via the standalone `/browser` page (peer to `/graph` and `/data`) and on the Claude sign-in screen; both render CDP screencast frames over `/cdp-screencast`, not the framebuffer. Virtual-mode Chromium is headless, so nothing renders onto the X display and the `/websockify` upgrade has no remaining consumer; deleting that stack is separate work.
205
205
 
206
- Upgrade and Cloudflare setup run as detached actions: `systemd-run --user` transient units per invocation with stdout+stderr persisted to `~/.maxy/logs/actions/<actionId>.log` and streamed to the UI via SSE. No boot-time service file exists for these.
206
+ The upgrade runs as a detached action: a `systemd-run --user --unit=maxy-action-<actionId> --collect` transient unit per invocation, with stdout+stderr persisted to `~/.<brand>/logs/actions/<actionId>.log` and streamed to the UI via SSE. The unit has its own cgroup, so it outlives both service restarts the installer performs. Its final log line is `[action-exit] code=<n>`, written by the wrapper, which is how the exit code survives a unit that `--collect` has already reaped. A sidecar `~/.<brand>/logs/actions/<actionId>.json` holds the package, start time and pre-upgrade version, so a modal reopened after a reload can still report what changed. No boot-time service file exists for these. Cloudflare setup is not an action; it runs PTY-native from admin chat.
207
207
 
208
- If an action looks stuck, read `~/.maxy/logs/actions/<actionId>.log` directly for the full output, or `journalctl --user --identifier=maxy-action-<actionId>` for systemd's record.
208
+ If an action looks stuck, read `~/.<brand>/logs/actions/<actionId>.log` directly for the full output, or `journalctl --user --identifier=maxy-action-<actionId>` for systemd's record. The lifecycle lines are tagged `[admin-action]` and keyed by `actionId`; they land in `~/.<brand>/logs/edge.log`, not `server.log`, because these routes run on the edge process.
209
209
 
210
210
  ## Linux laptops: snap-confined Chromium replacement
211
211
 
@@ -314,14 +314,20 @@ Empty output from step 3 = brand.json resolved cleanly and the badge reflects th
314
314
 
315
315
  ## Upgrading
316
316
 
317
- To upgrade {{productName}} to the latest version, ask {{productName}}: "Upgrade {{productName}}." The platform checks the current device identity (hostname and port via `system-status`), then re-runs the installer with explicit `--hostname` and `--port` flags to preserve them across the upgrade.
317
+ There are two ways to upgrade, and both run the same installer.
318
+
319
+ From the dashboard, press the download icon in the sidebar header (see below). From chat, ask {{productName}}: "Upgrade {{productName}}." The platform checks the current device identity (hostname and port via `system-status`), then re-runs the installer with explicit `--hostname` and `--port` flags to preserve them across the upgrade.
318
320
 
319
321
  The docs plugin (this plugin) is upgraded in the same step — you always have the documentation that matches your installed version.
320
322
 
321
- ### Automatic upgrade alert
323
+ ### Upgrading from the dashboard
324
+
325
+ The sidebar header shows the version you are running, under the product name, at all times. When a newer one is published a download icon appears beside it. There is no icon when you are already up to date, so the icon appearing is itself the news. If you work with the sidebar collapsed to the narrow rail, the version and the icon are hidden with the rest of the header text and a small coloured dot on the brand icon carries the same signal.
326
+
327
+ Pressing the icon opens the Software Update window, which shows the version you have against the version available and an Update button. Pressing Update runs the installer and streams its output into the window as it happens.
322
328
 
323
- {{productName}} checks for new releases on every admin session start whenever you log in, reload the page, or return to the admin chat. When a newer version is available, the Software Update window opens automatically showing your current and the latest version, with a one-click Upgrade button. Dismissing the window (click outside or the close button) defers the alert until your next login or reload; no alert is shown when you are already on the latest version.
329
+ Closing the window does not cancel the upgrade. The installer runs in its own background unit, so it keeps going whether or not anything is watching, and reopening the window reattaches to the run in progress.
324
330
 
325
- The upgrade runs inside a live terminal embedded in the Software Update window you see each installation step stream as it happens, and any password prompts from `sudo` appear directly in the terminal for you to answer. Closing the window does not cancel the upgrade; re-opening it reattaches to the same shell so you can see what happened while disconnected.
331
+ **The connection drops near the end of every upgrade, and that is expected.** The installer restarts the server as its last step, which cuts the browser's connection to it. The window says "Reconnecting" while that happens, then picks the output back up from exactly where it left off. Nothing is lost and nothing needs restarting. When it finishes, the window reports the exit code and whether the version actually moved.
326
332
 
327
- The header menu's version indicator still reflects real-time status: a green dot means you are up to date, and an accent-coloured dot means an upgrade is available. Opening the menu refreshes the version check, so a long-lived session can still surface an upgrade that became available after login without reloading the page.
333
+ The window reports a genuine failure in two shapes worth knowing. If the installer exits non-zero it says so with the code. If it exits cleanly but the version did not change, it says that too, rather than claiming success.
@@ -167,9 +167,11 @@ The Data search panel ranks results by combining vector similarity with keyword
167
167
 
168
168
  ## Software Update and Cloudflare Setup
169
169
 
170
- Both flows run on the native Claude Code PTY surface in admin chat. There is no in-app upgrade modal and no Cloudflare setup form the agent invokes the relevant Bash command directly and its stdout streams into chat verbatim.
170
+ Cloudflare setup runs on the native Claude Code PTY surface in admin chat: there is no Cloudflare setup form, the agent invokes the relevant Bash command directly and its stdout streams into chat verbatim.
171
171
 
172
- - **Software update.** Re-run the installer (`npx -y @rubytech/create-<brand>@latest`) from a shell; HeaderMenu's version row turns sage when `installed === latest`.
172
+ Software update has two doors. The PTY one is the same shape: the agent runs the installer via Bash and its stdout streams into chat. The dashboard one is a modal over the edge-hosted `/api/admin/actions` routes, which run the installer as a `systemd-run` transient unit and stream its log back by byte offset. Both re-run the same installer; neither mutates state directly.
173
+
174
+ - **Software update.** Re-run the installer (`npx -y @rubytech/create-<brand>@latest`), from a shell, from admin chat via the `upgrade` skill, or from the dashboard control in the sidebar brand head. The brand head shows the installed version at all times and grows a download icon only while `updateAvailable` is true.
173
175
  - **Cloudflare setup.** Operator asks in chat; the agent invokes `cloudflared` directly via the Bash tool, following the numbered steps in `plugins/cloudflare/references/manual-setup.md`. cloudflared's stdout and stderr stream into the PTY; the OAuth URL printed by `cloudflared tunnel login` is linkified by the terminal so the operator clicks it and authorises Cloudflare in their own browser.
174
176
 
175
177
  **Mid-turn stream-drop banners.** If a chat turn ends abruptly the bubble shows one of two messages depending on what actually happened. You see "Server is restarting — reconnect will happen automatically." only when the app server itself emits the restart signal — typically during a Software Update or a Cloudflare setup that re-launches the brand service. You see "Lost connection — retrying." when your browser's connection to the Pi dropped mid-stream while the server was still up — typically a flaky Wi-Fi moment or the tunnel hiccupping. Either way the chat resumes once the connection is back; the previously-rendered messages stay on screen so you don't lose context.
@@ -56,3 +56,22 @@ Ask {{productName}}: "What messages has the bot received?" or "Show recent Teleg
56
56
  **Can't send to a group:** The bot must be a member of the group. Add it via the group settings in Telegram, then try again.
57
57
 
58
58
  **Messages not arriving:** Make sure the bot hasn't been blocked. Try sending `/start` to the bot directly.
59
+
60
+ ## Sending from the dashboard
61
+
62
+ Open a Telegram conversation in the dashboard and you can type a reply straight
63
+ into it, the same way you already can with WhatsApp. The message goes out from
64
+ your bot.
65
+
66
+ Two things to know:
67
+
68
+ - **Direct messages only.** Group and supergroup chats show no message box. A
69
+ Telegram conversation is filed under the person who spoke, so a message sent
70
+ from a group thread would reach that one person privately rather than the
71
+ group — the box is hidden rather than doing something you did not intend.
72
+ - **History starts now.** Messages are only shown from the point this feature
73
+ was installed onward. A conversation that was already running looks empty
74
+ until the next message arrives, and then fills up from there.
75
+
76
+ Text only for now. Photos, files and voice notes are not available on Telegram
77
+ from the dashboard.
@@ -116,7 +116,7 @@ MERGE keys per label:
116
116
  - `:Contact` — `(accountId, contactId)` — write-time MERGE
117
117
  - `:PurchaseOrderLine` — `(accountId, poNumber, lineId)` — child of `:PurchaseOrder`
118
118
  - `:Note` — `(accountId, noteId)` — child of `:Job` via `(:Note)-[:ABOUT]->(:Job)`, append-only CREATE (a fresh `noteId` per note, not a content MERGE), so a second note never overwrites the first
119
- - `:Engineer` — `(accountId, personId)` — additional label on `:Person`, set via `memory-update`
119
+ - `:Engineer` — `(accountId, engineerId)` — additional label on `:Person`, set via `memory-update`
120
120
  - `:Supplier` — `(accountId, organizationId)` — additional label on `:Organization`
121
121
 
122
122
  ---
@@ -6,9 +6,9 @@ You are a public-facing agent for a business. Your soul and your knowledge are p
6
6
 
7
7
  Everything you know is in this prompt — your soul and the knowledge given with it. Answer only from it. If the answer is not there, say plainly that it is outside what you can help with, and offer to pass the question to the business. Never fill a gap from general training knowledge, and never invent a fact about the business, its products, prices, or people.
8
8
 
9
- ## You have no tools
9
+ ## You have one tool
10
10
 
11
- You have no tools. Do not search, look anything up, read files, run commands, or load skills. There is nothing for you to call so do not try. If a visitor asks for something that would need a lookup or an action, treat it as outside what you can help with and offer to pass it on. You cannot act, only answer.
11
+ You have exactly one tool: the reply tool for the channel you are talking on. It is how your answer reaches the visitor, so you call it for every reply you make. There is nothing else to call. You cannot search, look anything up, read files, run commands, or load skills, and there is no tool for any of it, so do not try. If a visitor asks for something that would need a lookup or an action, treat it as outside what you can help with and offer to pass it on. You answer; you do not act.
12
12
 
13
13
  ## How you behave
14
14
 
@@ -16,7 +16,7 @@ In your first message of every conversation, make clear in a natural way that th
16
16
 
17
17
  You are read-only. You cannot create, change, or delete anything. If a visitor uploads a file, it is saved for the business to review — you cannot read it or act on its contents, so say so honestly.
18
18
 
19
- Never describe your own workings, limits, or the fact that you have no tools unless a visitor asks. Visitors care about the business, not how you are built. Stay within the scope of the knowledge you were given, and decline politely when a request falls outside it.
19
+ Never describe your own workings, limits, or what you can and cannot call unless a visitor asks. Visitors care about the business, not how you are built. Stay within the scope of the knowledge you were given, and decline politely when a request falls outside it.
20
20
 
21
21
  ## Grounding
22
22
 
@@ -3,7 +3,7 @@ name: payroll-clerk
3
3
  description: "SiteDesk payroll clerk. Delegate when the operator wants to roll up field workers' daily check-ins into a per-worker timesheet and, for CIS subcontractors, an invoice for a period, reconciled to the penny and emailed to the office. Triggers are jobs like 'run the timesheets', 'do this week's invoices', 'roll up Dave's days'. Not for a new quote (that is the quoter) or an interim valuation (that is the valuer)."
4
4
  summary: "Your payroll clerk — turns field workers' daily check-ins into per-worker timesheets and CIS invoices, reconciled to the penny."
5
5
  model: claude-sonnet-5
6
- tools: Skill, Read, Write, Edit, Bash, Glob, Grep, mcp__plugin_browser_browser__browser-navigate, mcp__plugin_browser_browser__browser-pdf-save, mcp__plugin_email_email__email-send
6
+ tools: Skill, Read, Write, Edit, Bash, Glob, Grep, mcp__plugin_browser_browser__browser-navigate, mcp__plugin_browser_browser__browser-pdf-save, mcp__plugin_email_email__email-send, mcp__plugin_memory_memory__memory-search
7
7
  pattern_deliverable: false
8
8
  ---
9
9
 
@@ -17,7 +17,7 @@ A message from the agent that dispatched you arrives on the platform's own dispa
17
17
 
18
18
  You are the SiteDesk payroll clerk — the one owner of timesheet and CIS-invoice work for an independent service business's field crew. You own one deliverable: a per-worker timesheet and, for a CIS subcontractor, the matching invoice for a period, rendered to A4 PDFs and emailed to the office. The admin agent dispatches a brief to you; you run the `timesheet` skill and return the result.
19
19
 
20
- You never invent the operator's business content. Day counts come from the workers' own daily check-ins; rates, the office invoice address and each worker's engagement are the operator's data — you apply them faithfully. A figure you cannot source is a gap you flag, not a number you guess.
20
+ You never invent the operator's business content. Day counts come from the workers' own job sheets, filed on the data portal and carried into the graph as labour costs, which you read with `memory-search` and never write; rates, the office invoice address and each worker's engagement are the operator's data — you apply them faithfully. A figure you cannot source is a gap you flag, not a number you guess.
21
21
 
22
22
  ## What you own
23
23
 
@@ -22,12 +22,12 @@ maths ([references/roll-up.md](references/roll-up.md)), and the two documents
22
22
 
23
23
  - **Worker setup (per worker, once).** Capture the worker's engagement, day rate, UTR (CIS only),
24
24
  whether the 20% deduction shows, and the office invoice address. Store it as the `:Engineer` config
25
- on his `:Person`. The worker logs his days through the receive-only `worker-checkin` agent; the
26
- platform's multi-channel transport delivers those messages into one session.
25
+ on his `:Person`. The worker files his days as job sheets on the operator's data portal, against
26
+ his own visits; the device carries each one into the graph as a labour cost within a minute.
27
27
 
28
28
  - **Roll-up (operator-run, per period).** For a worker (or all workers) and a period:
29
- 1. Read his check-ins for the period by `personId` structure the worker's delivered messages
30
- into `:JobCost` records (one per working day) at this point.
29
+ 1. Read his labour `:JobCost` rows for the period with `memory-search`, by `engineerId`. They are
30
+ written by the portal visit exchange; this skill reads them and never creates them.
31
31
  2. Run `rollup.mjs compute <worker.json> <checkins.json>` — never compute by hand.
32
32
  3. Render the timesheet (and, for CIS, the invoice) from the computed figures.
33
33
  4. Run `rollup.mjs reconcile <computed.json> <netPence> <dayHundredths>` — both gates must pass.
@@ -42,6 +42,6 @@ total disagrees with the compute.
42
42
 
43
43
  ## Observability
44
44
 
45
- - one line per check-in read into a roll-up (worker id, source channel, date, site);
45
+ - one line per labour cost read into a roll-up (engineer id, source, date, site);
46
46
  - one line on generation: worker id, period, day-count, artefact filenames, email recipient;
47
47
  - the `op=reconcile-fail` line whenever a gate stops a send.
@@ -4,33 +4,51 @@ This skill ships these shapes, never a worker's data.
4
4
 
5
5
  ## `:Engineer` (operator config, one per worker)
6
6
 
7
- `(:Engineer { personId, accountId, name, engagement: "employee"|"cis", dayRatePence,
7
+ `(:Engineer { engineerId, accountId, name, engagement: "employee"|"cis", dayRatePence,
8
8
  utr?, showCisDeduction: bool, invoiceEmail, createdAt })`
9
9
 
10
- `personId` ties the worker to his `:Person`. `dayRatePence` is integer pence. `utr` is present
11
- only for `engagement: "cis"`. `invoiceEmail` is the office invoice address. Captured by the
12
- operator at runtime (see [setup.md](setup.md)), never written into this tree.
10
+ `engineerId` is the worker's key and the write-time MERGE key `(accountId, engineerId)`.
11
+ `dayRatePence` is integer pence. `utr` is present only for `engagement: "cis"`. `invoiceEmail` is
12
+ the office invoice address. Captured by the operator at runtime (see [setup.md](setup.md)), never
13
+ written into this tree.
13
14
 
14
- ## `:JobCost` (one labour cost per working day, `costType: "labour"`)
15
+ `Engineer` is an additional label on that worker's `:Person`; there is no standalone node. The rate
16
+ lives here, office-side, and never reaches the portal the worker files his day on.
15
17
 
16
- `(:JobCost { costId, costType: "labour", personId, accountId, source, receivedAt,
18
+ ## `:JobCost` (one labour cost per visit worked, `costType: "labour"`)
19
+
20
+ `(:JobCost { costId, costType: "labour", engineerId, accountId, jobId, source, receivedAt,
17
21
  date, site, description, dayHundredths, createdAt })`
18
22
 
19
- A logged labour day is an incurred labour cost. `costType` is `"labour"` and `costId` is the
20
- write-time MERGE key (`(accountId, costId)`) use a deterministic id such as `${personId}-${date}`
21
- so re-delivery of the same day stays idempotent. `personId` is the roll-up read key: the roll-up
22
- reads every labour `:JobCost` for the worker regardless of which channel produced it. `source`
23
- records the channel the message arrived on (`web`, `whatsapp`, …). `dayHundredths` is 100 for a
24
- full day; a smaller value only when the operator enabled part-day handling. One labour `:JobCost`
25
- per working day.
23
+ Time worked is an incurred labour cost. `costType` is `"labour"` and `costId` is the write-time
24
+ MERGE key (`(accountId, costId)`), formed as `${engineerId}-${visitId}`, so re-filing the same
25
+ visit merges rather than duplicating and the later sheet corrects the earlier one.
26
+
27
+ **One cost per visit, not per day.** A worker who attends two sites in a day files two sheets and
28
+ holds two labour costs on that date, each with its own `site` and its own share of the day. Keying
29
+ on the date instead would merge the second over the first and pay him for one of them; the roll-up
30
+ sums `dayHundredths` across every cost in the period, so several on one date add up to that day.
31
+
32
+ `engineerId` is the roll-up read key: the roll-up reads every labour `:JobCost` for the worker.
33
+ `date` is the day worked, which is what the roll-up filters a period on and what the timesheet
34
+ prints. `source` records where it came from (`portal`). `dayHundredths` is 100 for a full day; a
35
+ smaller value when the day is split across visits, or when the operator enabled part-day handling.
26
36
 
27
37
  Each `:JobCost` is linked to the engineer who incurred it with the canonical edge
28
- `(:JobCost)-[:INCURRED_BY]->(:Engineer)` (an additional label on that worker's `:Person`), and to
29
- its job with `(:Job)-[:HAS_COST]->(:JobCost)` the edge types are fixed by
30
- `schema-construction.md`; write no edge type absent from it. The edge satisfies the write-doctrine
31
- adjacency requirement (a node is written with at least one relationship); `personId` carries the
32
- same identity for roll-up reads.
33
-
34
- These records come from the worker's delivered messages. The platform's multi-channel transport
35
- delivers those messages into one session; the step that turns them into `:JobCost` records is this
36
- skill's roll-up (structure at roll-up see [roll-up.md](roll-up.md)).
38
+ `(:JobCost)-[:INCURRED_BY]->(:Engineer)`, and to its job with `(:Job)-[:HAS_COST]->(:JobCost)` the
39
+ edge types are fixed by `schema-construction.md`; write no edge type absent from it. The edges
40
+ satisfy the write-doctrine adjacency requirement (a node is written with at least one relationship);
41
+ `engineerId` carries the same identity for roll-up reads.
42
+
43
+ ## Where these records come from
44
+
45
+ The worker files a job sheet against one of his own visits on the operator's data portal, stating
46
+ the day worked and how much of it. The device carries that edit into the graph every sixty seconds,
47
+ writing the labour cost in the same statement as the visit's status, so a visit never reads as done
48
+ with the working day missing behind it. The day is attributed to the engineer the visit names rather
49
+ than to whoever filed the sheet, so an office person completing a worker's day still produces that
50
+ worker's cost.
51
+
52
+ A claim the device cannot date, or one on a visit with no attending engineer, is left unwritten and
53
+ counted in the journal rather than dated or attributed by guess. This skill reads these rows; it
54
+ never creates them.
@@ -3,13 +3,26 @@
3
3
  Days × rate and the CIS deduction are universal maths; they must reconcile to the penny, so they
4
4
  are computed by `rollup.mjs`, never by hand. The engine carries no operator content.
5
5
 
6
- ## Turning messages into check-ins (structure at roll-up)
7
-
8
- A worker's daily messages arrive over whatever channel and the platform delivers them into one session.
9
- At roll-up time, read the worker's messages for the period and structure each working day into one
10
- `:JobCost` (see [checkin-contract.md](checkin-contract.md)): `date`, `site`, `description`,
11
- `dayHundredths`. One message-day = one check-in. Structuring happens here, in the operator-run step,
12
- not in the unattended receive path — so the operator can review before anything is sent.
6
+ ## Reading the period's days
7
+
8
+ The days already exist as records. A worker files a job sheet against one of his own visits on the
9
+ operator's data portal, and the device writes that as one labour `:JobCost` (see
10
+ [checkin-contract.md](checkin-contract.md)) carrying `date`, `site`, `description` and
11
+ `dayHundredths`, within a minute of him filing it.
12
+
13
+ At roll-up time, read the worker's labour costs for the period with `memory-search`, by
14
+ `engineerId`, and filter on `date`. Never create them here. The key is `${engineerId}-${visitId}`,
15
+ so a re-filed sheet merges rather than duplicating; writing a record at roll-up would put a second,
16
+ differently-keyed entry beside one that already exists.
17
+
18
+ A worker who attends two sites in a day holds two costs on that date, each with its own site and
19
+ its own share of the day. The engine sums `dayHundredths` across every cost you hand it, so several
20
+ on one date add up to that day and each prints its own timesheet row.
21
+
22
+ A day the worker filed that the device could not use — no date it can key on, or a visit with no
23
+ attending engineer — was never written, and is counted in the journal as `undatedClaims` or as a
24
+ `labourClaims` count that does not drain. A period whose day total looks short is worth checking
25
+ there before it is treated as the worker's own record.
13
26
 
14
27
  ## The figures (integer pence; day amounts integer hundredths)
15
28
 
@@ -2,7 +2,9 @@
2
2
 
3
3
  The skill defines the questions; the operator's answers are his data. Per worker:
4
4
 
5
- - **Name** and the **`:Person`** he maps to.
5
+ - **Name**, the **`:Person`** he maps to, and his **`engineerId`** — the key everything else about
6
+ his days is read by. A worker with no `engineerId` has his job sheets refused rather than recorded
7
+ under a key nobody can reproduce.
6
8
  - **Engagement** — `employee` or `cis` (CIS subcontractor).
7
9
  - **Day rate** — captured in pounds, stored as integer pence (`dayRatePence`).
8
10
  - **UTR** — required when `engagement: cis`; omitted for employees.
@@ -14,7 +16,9 @@ Install-level, once:
14
16
 
15
17
  - **Invoice frequency** — weekly or monthly; this only sets the default roll-up period, never a
16
18
  scheduler. The roll-up is always operator-run.
17
- - **Part-day / overtime rule** — whether `dayHundredths` other than 100 are accepted, and how a
18
- part day is expressed. If disabled, every check-in is a full day.
19
+ - **Part-day rule** — whether `dayHundredths` below 100 are accepted, and how a part day is
20
+ expressed. If disabled, every day filed is a full day. The portal accepts an integer from 0 to
21
+ 100 and refuses anything above it, so a day longer than a full day cannot be filed and no
22
+ overtime rule is expressible here.
19
23
 
20
24
  No value is invented. A missing required value is a gap the agent flags, never a guess.