@rubytech/create-realagent-code 0.1.579 → 0.1.580
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.
- package/package.json +1 -1
- package/payload/platform/lib/telegram-media/dist/index.d.ts +45 -0
- package/payload/platform/lib/telegram-media/dist/index.d.ts.map +1 -0
- package/payload/platform/lib/telegram-media/dist/index.js +89 -0
- package/payload/platform/lib/telegram-media/dist/index.js.map +1 -0
- package/payload/platform/lib/telegram-media/src/__tests__/telegram-media.test.ts +104 -0
- package/payload/platform/lib/telegram-media/src/index.ts +102 -0
- package/payload/platform/lib/telegram-media/tsconfig.json +9 -0
- package/payload/platform/lib/telegram-media/vitest.config.ts +9 -0
- package/payload/platform/package.json +2 -2
- package/payload/platform/plugins/admin/PLUGIN.md +2 -2
- package/payload/platform/plugins/admin/hooks/__tests__/archive-ingest-surface-gate.test.sh +22 -0
- package/payload/platform/plugins/admin/hooks/__tests__/fs-schema-guard.test.sh +34 -0
- package/payload/platform/plugins/admin/hooks/__tests__/quote-path-write-gate.test.sh +25 -0
- package/payload/platform/plugins/admin/hooks/archive-ingest-surface-gate.sh +11 -4
- package/payload/platform/plugins/admin/hooks/fs-schema-guard.sh +2 -2
- package/payload/platform/plugins/admin/hooks/quote-path-write-gate.sh +6 -3
- package/payload/platform/plugins/admin/skills/whats-new/SKILL.md +7 -0
- package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-schema-shape.test.ts +23 -0
- package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-visit-status-write.test.ts +175 -3
- package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-visits-scope.test.ts +101 -0
- package/payload/platform/plugins/cloudflare/skills/data-portal/schema.sql +35 -1
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/visit-status.ts +73 -8
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/visits.ts +59 -8
- package/payload/platform/scripts/__tests__/hook-matcher-dispatch.test.sh +163 -0
- package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.js +6 -1
- package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.js.map +1 -1
- package/payload/platform/templates/specialists/agents/personal-assistant.md +1 -1
- package/payload/server/public/activity.html +6 -6
- package/payload/server/public/agents.html +5 -5
- package/payload/server/public/assets/{AdminLoginScreens-Bus-Y-rG.js → AdminLoginScreens-Dxv5t1YY.js} +1 -1
- package/payload/server/public/assets/AdminLoginScreens-Dxv5t1YY.js.br +0 -0
- package/payload/server/public/assets/AdminLoginScreens-Dxv5t1YY.js.gz +0 -0
- package/payload/server/public/assets/{AdminShell-Bsm7q3CW.js → AdminShell-BMslfZxN.js} +1 -1
- package/payload/server/public/assets/AdminShell-BMslfZxN.js.br +0 -0
- package/payload/server/public/assets/AdminShell-BMslfZxN.js.gz +0 -0
- package/payload/server/public/assets/{activity-BWqrCyYM.js → activity-kQ3w70LJ.js} +1 -1
- package/payload/server/public/assets/activity-kQ3w70LJ.js.br +0 -0
- package/payload/server/public/assets/activity-kQ3w70LJ.js.gz +0 -0
- package/payload/server/public/assets/{admin-zly7Y6EZ.js → admin-DapcMKCG.js} +1 -1
- package/payload/server/public/assets/admin-DapcMKCG.js.br +0 -0
- package/payload/server/public/assets/admin-DapcMKCG.js.gz +0 -0
- package/payload/server/public/assets/{agents-BsxGD1V1.js → agents-Cn_bZgnD.js} +1 -1
- package/payload/server/public/assets/agents-Cn_bZgnD.js.br +0 -0
- package/payload/server/public/assets/agents-Cn_bZgnD.js.gz +0 -0
- package/payload/server/public/assets/{browser-CRLxTaw2.js → browser-BE-GDk5-.js} +1 -1
- package/payload/server/public/assets/browser-BE-GDk5-.js.br +0 -0
- package/payload/server/public/assets/browser-BE-GDk5-.js.gz +0 -0
- package/payload/server/public/assets/{calendar-Bmp6qI_P.js → calendar-Di7BEHHN.js} +1 -1
- package/payload/server/public/assets/calendar-Di7BEHHN.js.br +0 -0
- package/payload/server/public/assets/calendar-Di7BEHHN.js.gz +0 -0
- package/payload/server/public/assets/chat-DLmikhie.js +1 -0
- package/payload/server/public/assets/chat-DLmikhie.js.br +1 -0
- package/payload/server/public/assets/chat-DLmikhie.js.gz +0 -0
- package/payload/server/public/assets/chevron-left-Id9VAZAz.js +1 -0
- package/payload/server/public/assets/chevron-left-Id9VAZAz.js.br +0 -0
- package/payload/server/public/assets/clock-DKpWWJbJ.js +1 -0
- package/payload/server/public/assets/clock-DKpWWJbJ.js.br +0 -0
- package/payload/server/public/assets/{copy-Bcjpxqzn.js → copy-nkxspu2J.js} +1 -1
- package/payload/server/public/assets/copy-nkxspu2J.js.br +0 -0
- package/payload/server/public/assets/copy-nkxspu2J.js.gz +0 -0
- package/payload/server/public/assets/data-C1_m9Xyf.js +1 -0
- package/payload/server/public/assets/data-C1_m9Xyf.js.br +0 -0
- package/payload/server/public/assets/data-C1_m9Xyf.js.gz +0 -0
- package/payload/server/public/assets/{field-ChKB2l_d.js → field-2rIXxGMt.js} +1 -1
- package/payload/server/public/assets/field-2rIXxGMt.js.br +0 -0
- package/payload/server/public/assets/field-2rIXxGMt.js.gz +0 -0
- package/payload/server/public/assets/{file-text-COZcf7mU.js → file-text-CdSqrscg.js} +1 -1
- package/payload/server/public/assets/file-text-CdSqrscg.js.br +0 -0
- package/payload/server/public/assets/file-text-CdSqrscg.js.gz +0 -0
- package/payload/server/public/assets/{graph-nnV47iqM.js → graph-BfBHHXaC.js} +1 -1
- package/payload/server/public/assets/graph-BfBHHXaC.js.br +0 -0
- package/payload/server/public/assets/graph-BfBHHXaC.js.gz +0 -0
- package/payload/server/public/assets/{graph-labels-x-1MT-3c.js → graph-labels-CHW2aBke.js} +1 -1
- package/payload/server/public/assets/graph-labels-CHW2aBke.js.br +0 -0
- package/payload/server/public/assets/graph-labels-CHW2aBke.js.gz +0 -0
- package/payload/server/public/assets/jsx-runtime-D43jgaWP.css +1 -0
- package/payload/server/public/assets/jsx-runtime-D43jgaWP.css.br +0 -0
- package/payload/server/public/assets/jsx-runtime-D43jgaWP.css.gz +0 -0
- package/payload/server/public/assets/{operator-BAO0-mjU.js → operator-BOiL8sxx.js} +1 -1
- package/payload/server/public/assets/operator-BOiL8sxx.js.br +0 -0
- package/payload/server/public/assets/operator-BOiL8sxx.js.gz +0 -0
- package/payload/server/public/assets/page-CZ6tyH8t.js +1 -0
- package/payload/server/public/assets/page-CZ6tyH8t.js.br +0 -0
- package/payload/server/public/assets/page-CZ6tyH8t.js.gz +0 -0
- package/payload/server/public/assets/{page-B-F08pod.js → page-Cd36Y0h6.js} +1 -1
- package/payload/server/public/assets/page-Cd36Y0h6.js.br +0 -0
- package/payload/server/public/assets/page-Cd36Y0h6.js.gz +0 -0
- package/payload/server/public/assets/play-7LTxCuLX.js +1 -0
- package/payload/server/public/assets/play-7LTxCuLX.js.br +0 -0
- package/payload/server/public/assets/play-7LTxCuLX.js.gz +0 -0
- package/payload/server/public/assets/{public-_HJsUohA.js → public-CVBav4FQ.js} +1 -1
- package/payload/server/public/assets/public-CVBav4FQ.js.br +0 -0
- package/payload/server/public/assets/public-CVBav4FQ.js.gz +0 -0
- package/payload/server/public/assets/{rotate-ccw-DJeAnYLQ.js → rotate-ccw-J9f-GWjm.js} +1 -1
- package/payload/server/public/assets/rotate-ccw-J9f-GWjm.js.br +0 -0
- package/payload/server/public/assets/rotate-ccw-J9f-GWjm.js.gz +0 -0
- package/payload/server/public/assets/{routines-CoIVXRCB.js → routines-C1AB-3FR.js} +1 -1
- package/payload/server/public/assets/routines-C1AB-3FR.js.br +0 -0
- package/payload/server/public/assets/routines-C1AB-3FR.js.gz +0 -0
- package/payload/server/public/assets/{skills-BjHt5lDG.js → skills-C4EfNKEx.js} +1 -1
- package/payload/server/public/assets/skills-C4EfNKEx.js.br +0 -0
- package/payload/server/public/assets/skills-C4EfNKEx.js.gz +0 -0
- package/payload/server/public/assets/{tasks-iUFfeA9_.js → tasks-DszD3aZl.js} +1 -1
- package/payload/server/public/assets/tasks-DszD3aZl.js.br +0 -0
- package/payload/server/public/assets/tasks-DszD3aZl.js.gz +0 -0
- package/payload/server/public/assets/{triangle-alert-Dwa986Gg.js → triangle-alert-B5WAyAkC.js} +1 -1
- package/payload/server/public/assets/triangle-alert-B5WAyAkC.js.br +4 -0
- package/payload/server/public/assets/triangle-alert-B5WAyAkC.js.gz +0 -0
- package/payload/server/public/assets/{useCopyFeedback-B1Jp6CsE.js → useCopyFeedback-B2nuNNJw.js} +1 -1
- package/payload/server/public/assets/useCopyFeedback-B2nuNNJw.js.br +0 -0
- package/payload/server/public/assets/useCopyFeedback-B2nuNNJw.js.gz +0 -0
- package/payload/server/public/assets/{useMediaQuery-CVZYjRf6.js → useMediaQuery-DUPJPHQt.js} +1 -1
- package/payload/server/public/assets/useMediaQuery-DUPJPHQt.js.br +0 -0
- package/payload/server/public/assets/useMediaQuery-DUPJPHQt.js.gz +0 -0
- package/payload/server/public/assets/{useVoiceRecorder-D5b4r4z0.js → useVoiceRecorder-BEgr0nHN.js} +1 -1
- package/payload/server/public/assets/useVoiceRecorder-BEgr0nHN.js.br +0 -0
- package/payload/server/public/assets/useVoiceRecorder-BEgr0nHN.js.gz +0 -0
- package/payload/server/public/assets/{wrench-BD7xwbV1.js → wrench-DZJZm17j.js} +1 -1
- package/payload/server/public/assets/wrench-DZJZm17j.js.br +0 -0
- package/payload/server/public/assets/wrench-DZJZm17j.js.gz +0 -0
- package/payload/server/public/browser.html +5 -5
- package/payload/server/public/calendar.html +8 -8
- package/payload/server/public/chat.html +14 -14
- package/payload/server/public/data.html +12 -12
- package/payload/server/public/field.html +3 -3
- package/payload/server/public/graph.html +10 -10
- package/payload/server/public/index.html +14 -14
- package/payload/server/public/operator.html +15 -15
- package/payload/server/public/public.html +14 -14
- package/payload/server/public/routines.html +8 -8
- package/payload/server/public/skills.html +6 -6
- package/payload/server/public/tasks.html +7 -7
- package/payload/server/server.js +965 -687
- package/payload/server/public/assets/AdminLoginScreens-Bus-Y-rG.js.br +0 -0
- package/payload/server/public/assets/AdminLoginScreens-Bus-Y-rG.js.gz +0 -0
- package/payload/server/public/assets/AdminShell-Bsm7q3CW.js.br +0 -0
- package/payload/server/public/assets/AdminShell-Bsm7q3CW.js.gz +0 -0
- package/payload/server/public/assets/activity-BWqrCyYM.js.br +0 -0
- package/payload/server/public/assets/activity-BWqrCyYM.js.gz +0 -0
- package/payload/server/public/assets/admin-zly7Y6EZ.js.br +0 -0
- package/payload/server/public/assets/admin-zly7Y6EZ.js.gz +0 -0
- package/payload/server/public/assets/agents-BsxGD1V1.js.br +0 -0
- package/payload/server/public/assets/agents-BsxGD1V1.js.gz +0 -0
- package/payload/server/public/assets/browser-CRLxTaw2.js.br +0 -0
- package/payload/server/public/assets/browser-CRLxTaw2.js.gz +0 -0
- package/payload/server/public/assets/calendar-Bmp6qI_P.js.br +0 -0
- package/payload/server/public/assets/calendar-Bmp6qI_P.js.gz +0 -0
- package/payload/server/public/assets/chat-ChObDKHy.js +0 -1
- package/payload/server/public/assets/chat-ChObDKHy.js.br +0 -0
- package/payload/server/public/assets/chat-ChObDKHy.js.gz +0 -0
- package/payload/server/public/assets/chevron-left-D2l4jMcd.js +0 -1
- package/payload/server/public/assets/chevron-left-D2l4jMcd.js.br +0 -0
- package/payload/server/public/assets/clock-CMdfsroW.js +0 -1
- package/payload/server/public/assets/clock-CMdfsroW.js.br +0 -0
- package/payload/server/public/assets/clock-CMdfsroW.js.gz +0 -0
- package/payload/server/public/assets/copy-Bcjpxqzn.js.br +0 -0
- package/payload/server/public/assets/copy-Bcjpxqzn.js.gz +0 -0
- package/payload/server/public/assets/data-CFB5Lgw-.js +0 -1
- package/payload/server/public/assets/data-CFB5Lgw-.js.br +0 -0
- package/payload/server/public/assets/data-CFB5Lgw-.js.gz +0 -0
- package/payload/server/public/assets/field-ChKB2l_d.js.br +0 -0
- package/payload/server/public/assets/field-ChKB2l_d.js.gz +0 -0
- package/payload/server/public/assets/file-text-COZcf7mU.js.br +0 -0
- package/payload/server/public/assets/file-text-COZcf7mU.js.gz +0 -0
- package/payload/server/public/assets/graph-labels-x-1MT-3c.js.br +0 -0
- package/payload/server/public/assets/graph-labels-x-1MT-3c.js.gz +0 -0
- package/payload/server/public/assets/graph-nnV47iqM.js.br +0 -0
- package/payload/server/public/assets/graph-nnV47iqM.js.gz +0 -0
- package/payload/server/public/assets/jsx-runtime-CeNZasfC.css +0 -1
- package/payload/server/public/assets/jsx-runtime-CeNZasfC.css.br +0 -0
- package/payload/server/public/assets/jsx-runtime-CeNZasfC.css.gz +0 -0
- package/payload/server/public/assets/operator-BAO0-mjU.js.br +0 -0
- package/payload/server/public/assets/operator-BAO0-mjU.js.gz +0 -0
- package/payload/server/public/assets/page-B-F08pod.js.br +0 -0
- package/payload/server/public/assets/page-B-F08pod.js.gz +0 -0
- package/payload/server/public/assets/page-BMOCy2aD.js +0 -1
- package/payload/server/public/assets/page-BMOCy2aD.js.br +0 -0
- package/payload/server/public/assets/page-BMOCy2aD.js.gz +0 -0
- package/payload/server/public/assets/play-DFtlk8Zs.js +0 -1
- package/payload/server/public/assets/play-DFtlk8Zs.js.br +0 -0
- package/payload/server/public/assets/play-DFtlk8Zs.js.gz +0 -0
- package/payload/server/public/assets/public-_HJsUohA.js.br +0 -0
- package/payload/server/public/assets/public-_HJsUohA.js.gz +0 -0
- package/payload/server/public/assets/rotate-ccw-DJeAnYLQ.js.br +0 -0
- package/payload/server/public/assets/rotate-ccw-DJeAnYLQ.js.gz +0 -0
- package/payload/server/public/assets/routines-CoIVXRCB.js.br +0 -0
- package/payload/server/public/assets/routines-CoIVXRCB.js.gz +0 -0
- package/payload/server/public/assets/skills-BjHt5lDG.js.br +0 -0
- package/payload/server/public/assets/skills-BjHt5lDG.js.gz +0 -0
- package/payload/server/public/assets/tasks-iUFfeA9_.js.br +0 -0
- package/payload/server/public/assets/tasks-iUFfeA9_.js.gz +0 -0
- package/payload/server/public/assets/triangle-alert-Dwa986Gg.js.br +0 -0
- package/payload/server/public/assets/triangle-alert-Dwa986Gg.js.gz +0 -0
- package/payload/server/public/assets/useCopyFeedback-B1Jp6CsE.js.br +0 -0
- package/payload/server/public/assets/useCopyFeedback-B1Jp6CsE.js.gz +0 -0
- package/payload/server/public/assets/useMediaQuery-CVZYjRf6.js.br +0 -0
- package/payload/server/public/assets/useMediaQuery-CVZYjRf6.js.gz +0 -0
- package/payload/server/public/assets/useVoiceRecorder-D5b4r4z0.js.br +0 -0
- package/payload/server/public/assets/useVoiceRecorder-D5b4r4z0.js.gz +0 -0
- package/payload/server/public/assets/wrench-BD7xwbV1.js.br +0 -0
- package/payload/server/public/assets/wrench-BD7xwbV1.js.gz +0 -0
- /package/payload/server/public/assets/{jsx-runtime-CjJ246F7.js → jsx-runtime-Cjug_Cyb.js} +0 -0
- /package/payload/server/public/assets/{jsx-runtime-CjJ246F7.js.br → jsx-runtime-Cjug_Cyb.js.br} +0 -0
- /package/payload/server/public/assets/{jsx-runtime-CjJ246F7.js.gz → jsx-runtime-Cjug_Cyb.js.gz} +0 -0
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
|
-
# fs-schema-guard — PreToolUse Write/Edit/NotebookEdit path guard.
|
|
2
|
+
# fs-schema-guard — PreToolUse Write/Edit/MultiEdit/NotebookEdit path guard.
|
|
3
3
|
#
|
|
4
4
|
# Enforces the seeded account-dir file schema (see <accountDir>/SCHEMA.md):
|
|
5
5
|
# - the target's first path segment must be in the allowed top-level set
|
|
@@ -61,7 +61,7 @@ except Exception:
|
|
|
61
61
|
|
|
62
62
|
TOOL_NAME=$(field '' tool_name)
|
|
63
63
|
case "$TOOL_NAME" in
|
|
64
|
-
Write|Edit) FILE_PATH=$(field tool_input file_path) ;;
|
|
64
|
+
Write|Edit|MultiEdit) FILE_PATH=$(field tool_input file_path) ;;
|
|
65
65
|
NotebookEdit) FILE_PATH=$(field tool_input notebook_path) ;;
|
|
66
66
|
*) exit 0 ;;
|
|
67
67
|
esac
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
|
-
# quote-path-write-gate — PreToolUse gate on Write / Edit / MultiEdit.
|
|
2
|
+
# quote-path-write-gate — PreToolUse gate on Write / Edit / MultiEdit / NotebookEdit.
|
|
3
3
|
#
|
|
4
4
|
# The client quote document has one owner, and that ownership is a capability:
|
|
5
5
|
# the quote-render tool is granted to the quoter's card and to no other, so no
|
|
@@ -49,10 +49,13 @@ try:
|
|
|
49
49
|
d = json.loads(os.environ["INPUT"])
|
|
50
50
|
except Exception:
|
|
51
51
|
sys.exit(0)
|
|
52
|
-
if (d.get("tool_name", "") or "") not in ("Write", "Edit", "MultiEdit"):
|
|
52
|
+
if (d.get("tool_name", "") or "") not in ("Write", "Edit", "MultiEdit", "NotebookEdit"):
|
|
53
53
|
sys.exit(0)
|
|
54
54
|
ti = d.get("tool_input", {}) or {}
|
|
55
|
-
|
|
55
|
+
# A notebook edit carries notebook_path; every other write tool carries
|
|
56
|
+
# file_path, and none of them carries notebook_path, so the last rung fires for
|
|
57
|
+
# a NotebookEdit alone.
|
|
58
|
+
target = ti.get("file_path") or ti.get("path") or ti.get("notebook_path") or ""
|
|
56
59
|
if not target:
|
|
57
60
|
sys.exit(0)
|
|
58
61
|
norm = os.path.normpath(target)
|
|
@@ -9,6 +9,13 @@ Invoked by the admin agent directly.
|
|
|
9
9
|
|
|
10
10
|
This is the platform's release timeline, newest first. Each entry shows the date it shipped and the version it shipped in, so you can tell the operator how current their install is. To compare, read the installed version from `capabilities-here` and match it against the versions below. Keep answers high level and in plain English; this is a summary, not a full commit log.
|
|
11
11
|
|
|
12
|
+
## 2026-08-08 (0.1.580)
|
|
13
|
+
|
|
14
|
+
- Telegram is now a first-class channel rather than a thin pass-through. Answers arrive formatted and stream as they are written instead of landing as one late block; the assistant can ask a question with buttons, send media, and issue an invoice; and the conversation pane has its own look instead of wearing WhatsApp's green.
|
|
15
|
+
- The Telegram composer no longer accepts a file it cannot send, and the buttons under a message stop working once the message they belong to is gone, rather than lingering.
|
|
16
|
+
- A visit shared by two workers now reaches the portal once and pays both. It was arriving twice, and a single sheet could only ever pay one of them.
|
|
17
|
+
- A safeguard on where files may be written now actually inspects multi-file edits. Two checks were registered for them and neither read them, so a whole class of edit passed unexamined.
|
|
18
|
+
|
|
12
19
|
## 2026-08-08 (0.1.579)
|
|
13
20
|
|
|
14
21
|
- What a public web visitor reads is checked again before they see it. The check that strips private paths had been removed from this door on the reasoning that the reader downstream would apply it; it was not being applied there either.
|
|
@@ -79,6 +79,29 @@ describe('portal schema shape', () => {
|
|
|
79
79
|
expect(SQL).toMatch(/UNIQUE\s*\(\s*accountId,\s*relPath,\s*generation\s*\)/)
|
|
80
80
|
})
|
|
81
81
|
|
|
82
|
+
it('keys a visits row on the attendance, so a shared visit is two rows', () => {
|
|
83
|
+
// Task 2512 made readVisitsForAccount return one row per attendance and left
|
|
84
|
+
// the table keyed on the visit, so the second worker's row failed the
|
|
85
|
+
// constraint, the insert threw, the generation was never flipped, and the
|
|
86
|
+
// account's published visits stopped refreshing. The key still rejects a TRUE
|
|
87
|
+
// duplicate — same visit, same worker, same generation — which is what stops
|
|
88
|
+
// a retried stage doubling a row.
|
|
89
|
+
expect(SQL).toMatch(/UNIQUE\s*\(\s*accountId,\s*visitId,\s*ownerId,\s*generation\s*\)/)
|
|
90
|
+
expect(SQL).not.toMatch(/UNIQUE\s*\(\s*accountId,\s*visitId,\s*generation\s*\)/)
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('gives a queued edit somewhere to say whose day it is', () => {
|
|
94
|
+
expect(tableBody('visit_status')).toMatch(/forOwnerId\s+TEXT\s+NOT NULL DEFAULT ''/)
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('documents the rebuild, because IF NOT EXISTS cannot widen a UNIQUE', () => {
|
|
98
|
+
// SQLite cannot drop the implicit index a UNIQUE clause creates, so an
|
|
99
|
+
// existing database needs the table rebuilt rather than altered. An
|
|
100
|
+
// undocumented rebuild is an operator watching a push fail with nothing
|
|
101
|
+
// telling them what to run.
|
|
102
|
+
expect(SQL).toContain('DROP TABLE visits;')
|
|
103
|
+
})
|
|
104
|
+
|
|
82
105
|
it('documents the hand-run ALTER for every added column', () => {
|
|
83
106
|
// IF NOT EXISTS adds no column to an existing table, so a portal deployed
|
|
84
107
|
// before this needs each one by hand. An undocumented column is an operator
|
|
@@ -72,11 +72,37 @@ function db(
|
|
|
72
72
|
const owner = bound[1] as string
|
|
73
73
|
let admitted = rows.filter((r) => r.accountId === acct && r.visitId === visitId)
|
|
74
74
|
if (sql.includes('AND v.ownerId = ?')) admitted = admitted.filter((r) => r.ownerId === owner)
|
|
75
|
-
|
|
76
|
-
|
|
75
|
+
// The grouping is honoured, not ignored (Task 2551). A shared visit
|
|
76
|
+
// admits one row per attendance, and a fake that reported both would
|
|
77
|
+
// pass a statement with no GROUP BY at all — the same defect shape as
|
|
78
|
+
// a fake that always reported one change passing a handler with no
|
|
79
|
+
// gate.
|
|
80
|
+
const changes = sql.includes('GROUP BY v.visitId')
|
|
81
|
+
? new Set(admitted.map((r) => r.visitId)).size
|
|
82
|
+
: admitted.length
|
|
83
|
+
if (changes) inserted.push(bound)
|
|
84
|
+
return { meta: { changes } }
|
|
77
85
|
},
|
|
78
86
|
async all<T>() {
|
|
79
|
-
|
|
87
|
+
// The Task 2511 pre-read became an .all() with Task 2551, because the
|
|
88
|
+
// visits table is keyed on the attendance and the ROW COUNT is what
|
|
89
|
+
// says whether this visit is shared. It applies THE SAME TWO FILTERS
|
|
90
|
+
// the insert does, so a test that deletes the scope branch in the
|
|
91
|
+
// endpoint sees this stop matching too.
|
|
92
|
+
if (!sql.includes('FROM visits')) return { results: [] as T[] }
|
|
93
|
+
const acct = bound[0] as string
|
|
94
|
+
const visitId = bound[bound.length - 1] as string
|
|
95
|
+
let admitted = rows.filter((r) => r.accountId === acct && r.visitId === visitId)
|
|
96
|
+
if (sql.includes('AND v.ownerId = ?')) {
|
|
97
|
+
admitted = admitted.filter((r) => r.ownerId === (bound[1] as string))
|
|
98
|
+
}
|
|
99
|
+
return {
|
|
100
|
+
results: admitted.map((r) => ({
|
|
101
|
+
status: r.status ?? '',
|
|
102
|
+
transitions:
|
|
103
|
+
typeof transitions === 'string' ? transitions : JSON.stringify(transitions),
|
|
104
|
+
})) as unknown as T[],
|
|
105
|
+
}
|
|
80
106
|
},
|
|
81
107
|
}
|
|
82
108
|
return stmt
|
|
@@ -603,3 +629,149 @@ describe('a move and a labour claim are separate edits (Task 2511)', () => {
|
|
|
603
629
|
expect(lines.join('\n')).toContain('v1')
|
|
604
630
|
})
|
|
605
631
|
})
|
|
632
|
+
|
|
633
|
+
/** ROWS, plus one visit two workers attend (Task 2551). Its own fixture, so the
|
|
634
|
+
* gate tests above keep counting what they were written to count. */
|
|
635
|
+
const SHARED_ROWS: Row[] = [
|
|
636
|
+
...ROWS,
|
|
637
|
+
{ accountId: 'acc-a', ownerId: 'alice', visitId: 'v5', status: 'booked' },
|
|
638
|
+
{ accountId: 'acc-a', ownerId: 'bob', visitId: 'v5', status: 'booked' },
|
|
639
|
+
]
|
|
640
|
+
|
|
641
|
+
describe('whose day a queued edit claims (Task 2551)', () => {
|
|
642
|
+
const sheet = (visitId: string, over: Record<string, unknown> = {}) => ({
|
|
643
|
+
visitId,
|
|
644
|
+
status: 'done',
|
|
645
|
+
dayHundredths: 100,
|
|
646
|
+
workDate: '2026-08-06',
|
|
647
|
+
...over,
|
|
648
|
+
})
|
|
649
|
+
|
|
650
|
+
/**
|
|
651
|
+
* The `forOwnerId` bind, by position.
|
|
652
|
+
*
|
|
653
|
+
* Read positionally and NOT with `toContain`: at own scope the session owner
|
|
654
|
+
* is bound twice already — once as the row's `ownerId` and once in the WHERE —
|
|
655
|
+
* so a containment assertion passes whether or not `forOwnerId` carries
|
|
656
|
+
* anything at all. The SELECT literals run accountId, ownerId, status,
|
|
657
|
+
* statusAt, dayHundredths, workDate, workNote, assignedOwnerId, startDate,
|
|
658
|
+
* forOwnerId, which puts it at index 9 at either scope.
|
|
659
|
+
*/
|
|
660
|
+
const forOwner = (bound: unknown[]) => bound[9]
|
|
661
|
+
|
|
662
|
+
it('stamps forOwnerId from the session for an own-scoped worker', async () => {
|
|
663
|
+
// The body carries no authority here, the same rule ownerId already follows:
|
|
664
|
+
// the session already decided who is writing.
|
|
665
|
+
const { database, inserted } = db(SHARED_ROWS, person('alice', 'own'), DECLARED)
|
|
666
|
+
const h = await processVisitStatus(
|
|
667
|
+
sheet('v5', { forOwnerId: 'bob' }),
|
|
668
|
+
'sess',
|
|
669
|
+
env(database),
|
|
670
|
+
() => {},
|
|
671
|
+
1,
|
|
672
|
+
)
|
|
673
|
+
expect(h.status).toBe(200)
|
|
674
|
+
expect(forOwner(inserted[0])).toBe('alice')
|
|
675
|
+
expect(inserted[0]).not.toContain('bob')
|
|
676
|
+
})
|
|
677
|
+
|
|
678
|
+
it('takes forOwnerId from the body for an account-scoped person', async () => {
|
|
679
|
+
// An office person filing a worker's sheet in is not the worker being paid,
|
|
680
|
+
// so theirs cannot come from the session.
|
|
681
|
+
const { database, inserted } = db(SHARED_ROWS, person('marie', 'account'), DECLARED)
|
|
682
|
+
const h = await processVisitStatus(
|
|
683
|
+
sheet('v5', { forOwnerId: 'bob' }),
|
|
684
|
+
'sess',
|
|
685
|
+
env(database),
|
|
686
|
+
() => {},
|
|
687
|
+
1,
|
|
688
|
+
)
|
|
689
|
+
expect(h.status).toBe(200)
|
|
690
|
+
expect(forOwner(inserted[0])).toBe('bob')
|
|
691
|
+
})
|
|
692
|
+
|
|
693
|
+
it('refuses a day claim on a shared visit that names nobody', async () => {
|
|
694
|
+
// Queuing it would pay the wrong worker or pay both. Refused at the door
|
|
695
|
+
// rather than left to the pull, which runs sixty seconds later with no
|
|
696
|
+
// screen to refuse to.
|
|
697
|
+
const { database, inserted } = db(SHARED_ROWS, person('marie', 'account'), DECLARED)
|
|
698
|
+
const h = await processVisitStatus(sheet('v5'), 'sess', env(database), () => {}, 1)
|
|
699
|
+
expect(h.status).toBe(400)
|
|
700
|
+
expect(h.payload).toMatchObject({ ok: false })
|
|
701
|
+
expect(inserted).toHaveLength(0)
|
|
702
|
+
})
|
|
703
|
+
|
|
704
|
+
it('leaves a single-worker day claim naming nobody exactly as it was', async () => {
|
|
705
|
+
const { database, inserted } = db(SHARED_ROWS, person('marie', 'account'), DECLARED)
|
|
706
|
+
const h = await processVisitStatus(sheet('v1'), 'sess', env(database), () => {}, 1)
|
|
707
|
+
expect(h.status).toBe(200)
|
|
708
|
+
expect(inserted).toHaveLength(1)
|
|
709
|
+
})
|
|
710
|
+
|
|
711
|
+
it('queues ONE row for a status edit on a shared visit, not one per attendance', async () => {
|
|
712
|
+
const { database } = db(SHARED_ROWS, person('marie', 'account'), DECLARED)
|
|
713
|
+
const h = await processVisitStatus(
|
|
714
|
+
{ visitId: 'v5', status: 'on-site' },
|
|
715
|
+
'sess',
|
|
716
|
+
env(database),
|
|
717
|
+
() => {},
|
|
718
|
+
1,
|
|
719
|
+
)
|
|
720
|
+
expect(h.payload).toMatchObject({ ok: true, queued: 1 })
|
|
721
|
+
})
|
|
722
|
+
|
|
723
|
+
it('refuses a forOwnerId longer than the cap rather than truncating it', async () => {
|
|
724
|
+
// The value reaches :Engineer.portalOwnerId as a match value through the
|
|
725
|
+
// pull. A truncated id matches a different person, or nobody, silently, and
|
|
726
|
+
// the person who filed the sheet is never told.
|
|
727
|
+
const { database, inserted } = db(SHARED_ROWS, person('marie', 'account'), DECLARED)
|
|
728
|
+
const h = await processVisitStatus(
|
|
729
|
+
sheet('v5', { forOwnerId: 'b'.repeat(MAX_OWNER_ID_LENGTH + 1) }),
|
|
730
|
+
'sess',
|
|
731
|
+
env(database),
|
|
732
|
+
() => {},
|
|
733
|
+
1,
|
|
734
|
+
)
|
|
735
|
+
expect(h.status).toBe(400)
|
|
736
|
+
expect(inserted).toHaveLength(0)
|
|
737
|
+
})
|
|
738
|
+
|
|
739
|
+
it('leaves an edit that claims no day carrying no worker', async () => {
|
|
740
|
+
// The field means whose DAY this edit claims, so an edit claiming none has
|
|
741
|
+
// no worker to be for. Stamping one anyway would report forWorker=true at
|
|
742
|
+
// the pull for a status change, and emit an op=applied field=forOwnerId line
|
|
743
|
+
// per edit on the commonest path in the exchange.
|
|
744
|
+
const { database, inserted } = db(SHARED_ROWS, person('alice', 'own'), DECLARED)
|
|
745
|
+
const h = await processVisitStatus(
|
|
746
|
+
{ visitId: 'v5', status: 'on-site' },
|
|
747
|
+
'sess',
|
|
748
|
+
env(database),
|
|
749
|
+
() => {},
|
|
750
|
+
1,
|
|
751
|
+
)
|
|
752
|
+
expect(h.status).toBe(200)
|
|
753
|
+
// `ownerId` is still stamped from the session. `forOwnerId` is the empty one.
|
|
754
|
+
expect(forOwner(inserted[0])).toBe('')
|
|
755
|
+
expect(inserted[0][1]).toBe('alice')
|
|
756
|
+
})
|
|
757
|
+
|
|
758
|
+
it('groups the insert SELECT so one edit is one row', async () => {
|
|
759
|
+
let seen = ''
|
|
760
|
+
const spy = {
|
|
761
|
+
prepare(sql: string) {
|
|
762
|
+
if (sql.startsWith('INSERT INTO visit_status')) seen = sql
|
|
763
|
+
return {
|
|
764
|
+
bind: () => ({
|
|
765
|
+
first: async () => person('marie', 'account'),
|
|
766
|
+
all: async () => ({
|
|
767
|
+
results: [{ status: 'booked', transitions: '[["booked","done"]]' }],
|
|
768
|
+
}),
|
|
769
|
+
run: async () => ({ meta: { changes: 1 } }),
|
|
770
|
+
}),
|
|
771
|
+
}
|
|
772
|
+
},
|
|
773
|
+
} as unknown as D1Database
|
|
774
|
+
await processVisitStatus({ visitId: 'v1', status: 'done' }, 'sess', env(spy), () => {}, 1)
|
|
775
|
+
expect(seen).toContain('GROUP BY v.visitId')
|
|
776
|
+
})
|
|
777
|
+
})
|
|
@@ -129,6 +129,19 @@ const ROWS: Row[] = [
|
|
|
129
129
|
{ accountId: 'acc-b', ownerId: 'alice', visitId: 'v4' },
|
|
130
130
|
]
|
|
131
131
|
|
|
132
|
+
/**
|
|
133
|
+
* ROWS, plus one visit two workers attend (Task 2551).
|
|
134
|
+
*
|
|
135
|
+
* Its own fixture rather than an extra pair of entries in ROWS: every assertion
|
|
136
|
+
* in the two describe blocks above counts the visits it admits, and widening the
|
|
137
|
+
* shared fixture would rewrite tests this task is not changing.
|
|
138
|
+
*/
|
|
139
|
+
const SHARED_ROWS: Row[] = [
|
|
140
|
+
...ROWS,
|
|
141
|
+
{ accountId: 'acc-a', ownerId: 'alice', visitId: 'v5' },
|
|
142
|
+
{ accountId: 'acc-a', ownerId: 'bob', visitId: 'v5' },
|
|
143
|
+
]
|
|
144
|
+
|
|
132
145
|
const ids = (h: { payload: Record<string, unknown> }) =>
|
|
133
146
|
(h.payload.visits as Row[]).map((v) => v.visitId).sort()
|
|
134
147
|
|
|
@@ -501,3 +514,91 @@ describe('the planner write half reads through the same gate (Task 2511)', () =>
|
|
|
501
514
|
expect(candidateSelect!).not.toContain('v.ownerId = ?')
|
|
502
515
|
})
|
|
503
516
|
})
|
|
517
|
+
|
|
518
|
+
describe('a visit two workers share (Task 2551)', () => {
|
|
519
|
+
it('is one card naming both workers at account scope', async () => {
|
|
520
|
+
const h = await processVisits(
|
|
521
|
+
'sess',
|
|
522
|
+
env(db(SHARED_ROWS, asPerson('marie', 'account'))),
|
|
523
|
+
() => {},
|
|
524
|
+
1,
|
|
525
|
+
)
|
|
526
|
+
const shared = (h.payload.visits as Array<{ visitId: string; owners: string[] }>).filter(
|
|
527
|
+
(v) => v.visitId === 'v5',
|
|
528
|
+
)
|
|
529
|
+
expect(shared).toHaveLength(1)
|
|
530
|
+
expect([...shared[0].owners].sort()).toEqual(['alice', 'bob'])
|
|
531
|
+
})
|
|
532
|
+
|
|
533
|
+
it('reaches BOTH workers own-scope days', async () => {
|
|
534
|
+
// The failure Task 2512 exists to end: a worker missing a job from their day
|
|
535
|
+
// sees nothing and has nothing to report but confusion.
|
|
536
|
+
for (const who of ['alice', 'bob']) {
|
|
537
|
+
const h = await processVisits('sess', env(db(SHARED_ROWS, asPerson(who, 'own'))), () => {}, 1)
|
|
538
|
+
expect(ids(h)).toContain('v5')
|
|
539
|
+
}
|
|
540
|
+
})
|
|
541
|
+
|
|
542
|
+
it('never names a colleague to an own-scoped person', async () => {
|
|
543
|
+
// The fold runs AFTER the SQL filter, so alice's copy of the shared card
|
|
544
|
+
// carries alice and nobody else. Folding first would hand her bob's id,
|
|
545
|
+
// which is the staff list the file drop deliberately withholds.
|
|
546
|
+
const h = await processVisits(
|
|
547
|
+
'sess',
|
|
548
|
+
env(db(SHARED_ROWS, asPerson('alice', 'own'))),
|
|
549
|
+
() => {},
|
|
550
|
+
1,
|
|
551
|
+
)
|
|
552
|
+
const shared = (h.payload.visits as Array<{ visitId: string; owners: string[] }>).find(
|
|
553
|
+
(v) => v.visitId === 'v5',
|
|
554
|
+
)
|
|
555
|
+
expect(shared!.owners).toEqual(['alice'])
|
|
556
|
+
})
|
|
557
|
+
|
|
558
|
+
it('keeps ownerId as the first of owners, so the planner still groups', async () => {
|
|
559
|
+
const h = await processVisits(
|
|
560
|
+
'sess',
|
|
561
|
+
env(db(SHARED_ROWS, asPerson('marie', 'account'))),
|
|
562
|
+
() => {},
|
|
563
|
+
1,
|
|
564
|
+
)
|
|
565
|
+
for (const v of h.payload.visits as Array<{ ownerId: string; owners: string[] }>) {
|
|
566
|
+
expect(v.ownerId).toBe(v.owners[0])
|
|
567
|
+
}
|
|
568
|
+
})
|
|
569
|
+
|
|
570
|
+
it('orders the read by ownerId, so the worker list does not vary between reads', async () => {
|
|
571
|
+
prepared.length = 0
|
|
572
|
+
await processVisits('sess', env(db(SHARED_ROWS, asPerson('marie', 'account'))), () => {}, 1)
|
|
573
|
+
const visitsSelect = prepared.find((s) => s.includes('FROM visits v'))
|
|
574
|
+
expect(visitsSelect!).toContain('v.ownerId ASC')
|
|
575
|
+
})
|
|
576
|
+
|
|
577
|
+
it('counts cards, not attendances, in the log', async () => {
|
|
578
|
+
const lines: string[] = []
|
|
579
|
+
await processVisits(
|
|
580
|
+
'sess',
|
|
581
|
+
env(db(SHARED_ROWS, asPerson('marie', 'account'))),
|
|
582
|
+
(l) => lines.push(l),
|
|
583
|
+
1,
|
|
584
|
+
)
|
|
585
|
+
// v1, v2, v3, v5 — four cards from five rows.
|
|
586
|
+
expect(lines.join('\n')).toContain('visits=4')
|
|
587
|
+
})
|
|
588
|
+
|
|
589
|
+
it('does not multiply a candidate by the attendances on its visit', async () => {
|
|
590
|
+
// A candidate exists only for a visit carrying NO attendance, and such a
|
|
591
|
+
// visit stages exactly one row, so the join on (visitId, generation) cannot
|
|
592
|
+
// fan out. Pinned rather than assumed: the join is what would silently
|
|
593
|
+
// double an offer if the push ever staged a candidate against a staffed
|
|
594
|
+
// visit.
|
|
595
|
+
const against = [{ ...CANDIDATES[0], visitId: 'v5' }]
|
|
596
|
+
const h = await processVisits(
|
|
597
|
+
'sess',
|
|
598
|
+
env(db(SHARED_ROWS, asPerson('marie', 'account'), ROSTER, META, against)),
|
|
599
|
+
() => {},
|
|
600
|
+
1,
|
|
601
|
+
)
|
|
602
|
+
expect(h.payload.candidates as unknown[]).toHaveLength(1)
|
|
603
|
+
})
|
|
604
|
+
})
|
|
@@ -289,6 +289,25 @@ CREATE TABLE IF NOT EXISTS directory_state (
|
|
|
289
289
|
-- `checklistDone` against `checklistTotal` is the one pair read together: a
|
|
290
290
|
-- count of ticked items with no total says nothing about whether the work is
|
|
291
291
|
-- finished.
|
|
292
|
+
--
|
|
293
|
+
-- ONE ROW PER ATTENDANCE, NOT PER VISIT (Task 2551). `ownerId` is part of the
|
|
294
|
+
-- key. Task 2512 made a visit two workers share representable and changed the
|
|
295
|
+
-- push to return one row per attendance, and left this key on (accountId,
|
|
296
|
+
-- visitId, generation): the second worker's row failed the constraint, the
|
|
297
|
+
-- insert threw, the generation was never flipped, and the account's published
|
|
298
|
+
-- visits stopped refreshing for as long as the shared visit existed. A TRUE
|
|
299
|
+
-- duplicate — the same visit, the same worker, the same generation — is still
|
|
300
|
+
-- rejected, which is what stops a retried stage doubling a row.
|
|
301
|
+
--
|
|
302
|
+
-- IF NOT EXISTS CANNOT APPLY THIS TO AN EXISTING DATABASE, and unlike the added
|
|
303
|
+
-- columns above this one is not an ALTER: SQLite cannot drop the implicit index
|
|
304
|
+
-- a UNIQUE clause creates, so the table is rebuilt. On a database that predates
|
|
305
|
+
-- it, by hand:
|
|
306
|
+
-- DROP TABLE visits;
|
|
307
|
+
-- then re-apply this file. No copy is needed and none should be made: `visits`
|
|
308
|
+
-- is a mirror of the graph rather than a source of truth, so the next push
|
|
309
|
+
-- claims visits_state.currentGeneration + 1, writes the whole set, and flips.
|
|
310
|
+
-- The cost is one listing showing nothing, for at most sixty seconds.
|
|
292
311
|
CREATE TABLE IF NOT EXISTS visits (
|
|
293
312
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
294
313
|
accountId TEXT NOT NULL,
|
|
@@ -305,7 +324,7 @@ CREATE TABLE IF NOT EXISTS visits (
|
|
|
305
324
|
checklistTotal INTEGER NOT NULL DEFAULT 0,
|
|
306
325
|
signatures INTEGER NOT NULL DEFAULT 0,
|
|
307
326
|
generation INTEGER NOT NULL DEFAULT 0,
|
|
308
|
-
UNIQUE (accountId, visitId, generation)
|
|
327
|
+
UNIQUE (accountId, visitId, ownerId, generation)
|
|
309
328
|
);
|
|
310
329
|
|
|
311
330
|
CREATE INDEX IF NOT EXISTS visits_account ON visits (accountId);
|
|
@@ -371,6 +390,20 @@ CREATE TABLE IF NOT EXISTS visits_state (
|
|
|
371
390
|
-- values lexically, so an offset-bearing or date-only value reads the wrong
|
|
372
391
|
-- window and a real double-booking goes unreported. The door refuses any other
|
|
373
392
|
-- shape rather than normalising one.
|
|
393
|
+
--
|
|
394
|
+
-- `forOwnerId` is WHOSE DAY this edit claims (Task 2551), and '' means it names
|
|
395
|
+
-- nobody — the same absent-sentinel convention `ownerId` uses above. It exists
|
|
396
|
+
-- because a visit two workers share names two attending engineers, and the
|
|
397
|
+
-- pull's `size(es) = 1` gate turned one submitted sheet into nothing written at
|
|
398
|
+
-- all rather than a full day paid to each of them. The door stamps it from the
|
|
399
|
+
-- resolved session for an own-scoped person and reads it from the body for an
|
|
400
|
+
-- account-scoped one filling a worker's sheet in.
|
|
401
|
+
--
|
|
402
|
+
-- IT IS NOT `ownerId` ABOVE. That column is who was signed in when the edit was
|
|
403
|
+
-- made; an office person filing a worker's sheet is not the worker being paid.
|
|
404
|
+
--
|
|
405
|
+
-- On a database that predates the column, by hand:
|
|
406
|
+
-- ALTER TABLE visit_status ADD COLUMN forOwnerId TEXT NOT NULL DEFAULT '';
|
|
374
407
|
CREATE TABLE IF NOT EXISTS visit_status (
|
|
375
408
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
376
409
|
accountId TEXT NOT NULL,
|
|
@@ -383,6 +416,7 @@ CREATE TABLE IF NOT EXISTS visit_status (
|
|
|
383
416
|
workNote TEXT NOT NULL DEFAULT '',
|
|
384
417
|
assignedOwnerId TEXT NOT NULL DEFAULT '',
|
|
385
418
|
startDate TEXT NOT NULL DEFAULT '',
|
|
419
|
+
forOwnerId TEXT NOT NULL DEFAULT '',
|
|
386
420
|
claimed INTEGER NOT NULL DEFAULT 0
|
|
387
421
|
);
|
|
388
422
|
|
|
@@ -166,6 +166,35 @@ export async function processVisitStatus(
|
|
|
166
166
|
|
|
167
167
|
const scoped = session.recordScope === 'own'
|
|
168
168
|
|
|
169
|
+
/**
|
|
170
|
+
* WHOSE DAY this edit claims (Task 2551), and '' when it names nobody.
|
|
171
|
+
*
|
|
172
|
+
* Stamped from the resolved session for an own-scoped person and NEVER from
|
|
173
|
+
* the body — the same rule `ownerId` follows above, and for the same reason:
|
|
174
|
+
* the session already decided who is writing. An account-scoped person is
|
|
175
|
+
* filling a worker's sheet in, so theirs comes from the body; they are not the
|
|
176
|
+
* worker being paid.
|
|
177
|
+
*
|
|
178
|
+
* ONLY AN EDIT THAT CLAIMS A DAY CARRIES ONE. An edit claiming no day has no
|
|
179
|
+
* worker to be for, so stamping one would report `forWorker=true` at the pull
|
|
180
|
+
* for a status change and emit an `op=applied field=forOwnerId` line per edit
|
|
181
|
+
* on the commonest path in the exchange. The pull reads this field in the
|
|
182
|
+
* labour shape alone. A `forOwnerId` sent on an edit that claims no day is
|
|
183
|
+
* therefore IGNORED rather than refused, the same treatment a body-supplied
|
|
184
|
+
* `ownerId` already gets: it carries nothing here.
|
|
185
|
+
*
|
|
186
|
+
* Capped like `assignedOwnerId` and for the same reason: the value reaches
|
|
187
|
+
* `:Engineer.portalOwnerId` as a match value through the pull, and a truncated
|
|
188
|
+
* id matches a different person, or nobody, silently.
|
|
189
|
+
*/
|
|
190
|
+
const named = scoped ? session.ownerId : typeof b.forOwnerId === 'string' ? b.forOwnerId : ''
|
|
191
|
+
const forOwnerId = dayHundredths > 0 ? named : ''
|
|
192
|
+
if (forOwnerId.length > MAX_OWNER_ID_LENGTH) {
|
|
193
|
+
log(`[data-portal] op=visit-status owner=${q(session.ownerId)} result=for-owner-too-long`)
|
|
194
|
+
log(`[data-portal] op=rejected visitId=${q(visitId)} reason=for-owner-too-long`)
|
|
195
|
+
return { status: 400, payload: { ok: false, error: 'forOwnerId too long' } }
|
|
196
|
+
}
|
|
197
|
+
|
|
169
198
|
// The visit's CURRENT status and the account's declared moves, read under THE
|
|
170
199
|
// SAME TWO FILTERS the insert applies (Task 2511).
|
|
171
200
|
//
|
|
@@ -188,11 +217,19 @@ export async function processVisitStatus(
|
|
|
188
217
|
(scoped ? ' AND v.ownerId = ?' : '') +
|
|
189
218
|
' AND v.visitId = ?'
|
|
190
219
|
const currentStmt = env.DB.prepare(currentSql)
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
220
|
+
// ALL, not first (Task 2551). `visits` is keyed on the attendance, so a visit
|
|
221
|
+
// two workers share is two rows here and THE COUNT is what says the visit is
|
|
222
|
+
// shared. Both rows carry the same `status` and the same `transitions`, both
|
|
223
|
+
// being per visit, so reading the first for those two values is still right.
|
|
224
|
+
const admitted =
|
|
225
|
+
(
|
|
226
|
+
await (
|
|
227
|
+
scoped
|
|
228
|
+
? currentStmt.bind(env.PORTAL_ACCOUNT_ID, session.ownerId, visitId)
|
|
229
|
+
: currentStmt.bind(env.PORTAL_ACCOUNT_ID, visitId)
|
|
230
|
+
).all<{ status: string; transitions: string }>()
|
|
231
|
+
).results ?? []
|
|
232
|
+
const current = admitted[0] ?? null
|
|
196
233
|
|
|
197
234
|
if (!current) {
|
|
198
235
|
// The SAME single answer the insert gives below, and for the same reason:
|
|
@@ -207,6 +244,26 @@ export async function processVisitStatus(
|
|
|
207
244
|
return { status: 404, payload: { ok: false, error: 'not found' } }
|
|
208
245
|
}
|
|
209
246
|
|
|
247
|
+
// A DAY CLAIMED AGAINST A VISIT TWO WORKERS SHARE, NAMING NEITHER, IS REFUSED
|
|
248
|
+
// (Task 2551). Queuing it would pay the wrong worker or pay both, and the pull
|
|
249
|
+
// runs sixty seconds later with no screen to refuse to. An own-scoped person
|
|
250
|
+
// never reaches this: their read admits their own attendance and no other, and
|
|
251
|
+
// their `forOwnerId` is stamped from the session above.
|
|
252
|
+
//
|
|
253
|
+
// Only a LABOUR claim. A status edit on a shared visit is one edit about the
|
|
254
|
+
// visit, and the grouped insert below queues one row for it.
|
|
255
|
+
if (dayHundredths > 0 && admitted.length > 1 && forOwnerId === '') {
|
|
256
|
+
log(
|
|
257
|
+
`[data-portal] op=visit-status owner=${q(session.ownerId)} ` +
|
|
258
|
+
`scope=${session.recordScope} result=ambiguous-worker`,
|
|
259
|
+
)
|
|
260
|
+
log(`[data-portal] op=rejected visitId=${q(visitId)} reason=ambiguous-worker`)
|
|
261
|
+
return {
|
|
262
|
+
status: 400,
|
|
263
|
+
payload: { ok: false, error: 'a shared record needs forOwnerId to say whose day this is' },
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
210
267
|
// A write whose status equals the current one is NOT A MOVE. `[from, to]`
|
|
211
268
|
// declares a move, and no account declares a self-transition, so without this
|
|
212
269
|
// a pure re-timing would be refused for failing to be a transition — and this
|
|
@@ -235,14 +292,20 @@ export async function processVisitStatus(
|
|
|
235
292
|
}
|
|
236
293
|
|
|
237
294
|
const sql =
|
|
238
|
-
`INSERT INTO visit_status (accountId, ownerId, status, statusAt, dayHundredths, workDate, workNote, assignedOwnerId, startDate, claimed, visitId)
|
|
239
|
-
SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, v.visitId
|
|
295
|
+
`INSERT INTO visit_status (accountId, ownerId, status, statusAt, dayHundredths, workDate, workNote, assignedOwnerId, startDate, forOwnerId, claimed, visitId)
|
|
296
|
+
SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, v.visitId
|
|
240
297
|
FROM visits v
|
|
241
298
|
JOIN visits_state s
|
|
242
299
|
ON s.accountId = v.accountId AND s.currentGeneration = v.generation
|
|
243
300
|
WHERE v.accountId = ?` +
|
|
244
301
|
(scoped ? ' AND v.ownerId = ?' : '') +
|
|
245
|
-
' AND v.visitId = ?'
|
|
302
|
+
' AND v.visitId = ?' +
|
|
303
|
+
// ONE EDIT IS ONE ROW (Task 2551). `visits` is keyed on the attendance, so
|
|
304
|
+
// without this an account-scoped edit on a shared visit would queue one row
|
|
305
|
+
// per worker and the pull would apply the same status once per attendance.
|
|
306
|
+
// THE WHERE IS UNTOUCHED, so the SELECT is still the gate; the grouping only
|
|
307
|
+
// collapses what that gate already admitted.
|
|
308
|
+
' GROUP BY v.visitId'
|
|
246
309
|
const statusAt = new Date(nowMs).toISOString()
|
|
247
310
|
const stmt = env.DB.prepare(sql)
|
|
248
311
|
const bound = scoped
|
|
@@ -256,6 +319,7 @@ export async function processVisitStatus(
|
|
|
256
319
|
workNote,
|
|
257
320
|
assignedOwnerId,
|
|
258
321
|
startDate,
|
|
322
|
+
forOwnerId,
|
|
259
323
|
env.PORTAL_ACCOUNT_ID,
|
|
260
324
|
session.ownerId,
|
|
261
325
|
visitId,
|
|
@@ -270,6 +334,7 @@ export async function processVisitStatus(
|
|
|
270
334
|
workNote,
|
|
271
335
|
assignedOwnerId,
|
|
272
336
|
startDate,
|
|
337
|
+
forOwnerId,
|
|
273
338
|
env.PORTAL_ACCOUNT_ID,
|
|
274
339
|
visitId,
|
|
275
340
|
)
|