@bongos/core 1.19.1060 → 1.19.1062

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.
@@ -14,6 +14,9 @@
14
14
  // task 1004317): POST /copy-desk/pages/:pageId/claim opens or takes a page's
15
15
  // tweak round as a TASK with a web claim, and POST /copy-desk/pages/:pageId/asks
16
16
  // files a page ask, a flag row whose target is a page id. Neither stores copy.
17
+ // TW08 (task 1004319) added the draft autosave and the submit, and TW09 (task
18
+ // 1004320) the Word round trip, GET and POST /copy-desk/pages/:pageId/draft.docx:
19
+ // the upload lands in the round's DRAFT, through the autosave's own port call.
17
20
  //
18
21
  // THE RANK SHAPE, AND WHY IT IS ASYMMETRIC:
19
22
  //
@@ -53,6 +56,7 @@ const proposals = require('../proposals');
53
56
  const pageData = require('../page-data');
54
57
  const pageStatus = require('../page-status');
55
58
  const pages = require('../pages');
59
+ const docx = require('../docx');
56
60
 
57
61
  const { validateOrRespond } = api;
58
62
 
@@ -103,6 +107,133 @@ const CLAIM_BODY_SCHEMA = Object.freeze({});
103
107
  const ASK_BODY_SCHEMA = Object.freeze({
104
108
  reason: { required: true, type: 'string', maxLength: flags.MAX_REASON_CHARS, minLength: 1 },
105
109
  });
110
+ // The draft autosave (task 1004319 / TW08): the reading the editor was built
111
+ // on, and every changed line as { key, after }. Nothing else, and in
112
+ // particular no `before`, `section`, `placement` or file: every fact about a
113
+ // target is resolved from the page reading (pages.resolveDraft).
114
+ const DRAFT_BODY_SCHEMA = Object.freeze({
115
+ reading_hash: { required: true, type: 'string', maxLength: 64, minLength: 1 },
116
+ lines: { required: true, type: 'array', maxItems: pages.MAX_DRAFT_LINES },
117
+ });
118
+ // The submit takes nothing: it freezes the draft already on the task.
119
+ const SUBMIT_BODY_SCHEMA = Object.freeze({});
120
+
121
+ // The refusals of pages.resolveDraft / freezeBatch, as HTTP statuses.
122
+ const PAGE_REFUSAL_STATUS = Object.freeze({
123
+ reading_missing: 503,
124
+ reading_moved: 409,
125
+ lines_refused: 409,
126
+ nothing_to_submit: 409,
127
+ multiple_blocks: 409,
128
+ block_unreadable: 409,
129
+ block_schema_unknown: 409,
130
+ block_field_missing: 409,
131
+ wrong_page: 409,
132
+ // The Word upload (task 1004320, ADR 0341 D12).
133
+ structure_changed: 409,
134
+ not_a_docx: 415,
135
+ upload_too_large: 413,
136
+ });
137
+ const PAGE_REFUSAL_MEANS = Object.freeze({
138
+ reading_moved: 'The page changed since the editor loaded it, so a line key may now mean a different line. Reload the page to get the current reading; nothing was saved.',
139
+ unknown_line: 'The page reading has no line with that key. Reload the editor.',
140
+ line_empty: 'A line cannot be rewritten to nothing. Removing text from a page is a code change; leave the line as it is or give it new words.',
141
+ line_too_long: `A line holds at most ${pages.MAX_LINE_CHARS} characters.`,
142
+ lines_refused: 'Some lines cannot be frozen as written: the page no longer shows them (target_gone), shows the same words in more than one place (ambiguous_target), or the rewrite dropped or added a {…} hole (placeholder_mismatch). Fix or remove each named line and submit again; nothing was submitted.',
143
+ nothing_to_submit: 'The draft has no rewritten lines yet.',
144
+ structure_changed: 'The file\'s lines no longer match the page: a paragraph was added, removed or moved, or a heading changed. Each one is listed. Put them back (one paragraph per line, in the page\'s order) or download the page again, and upload once more; nothing was saved.',
145
+ not_a_docx: 'Upload the .docx file this page downloaded, as the request body. Nothing was saved.',
146
+ upload_too_large: `A page file is at most ${Math.round(docx.MAX_UPLOAD_BYTES / (1024 * 1024))} MB. Nothing was saved.`,
147
+ });
148
+ // The upload's answers when the file is readable but is not this page's, or not
149
+ // this reading's (task 1004320, ADR 0341 D12).
150
+ const UPLOAD_WRONG_PAGE_MEANS = 'This file is not the download of this page, or the program that saved it dropped the page details Word keeps. Download this page again and make your changes in that file. Nothing was saved.';
151
+ const UPLOAD_READING_MOVED_MEANS = 'The page has changed since this file was downloaded, so its lines may no longer line up with the page. Download the page again and copy your changes across. Nothing was saved.';
152
+
153
+ // The upload's body is the .docx itself, never JSON (the global parser only
154
+ // reads application/json, so it passes this through). Capped at the parser, and
155
+ // never inflated from a Content-Encoding, so the cap is the bytes on the wire.
156
+ const DOCX_BODY_TYPES = [docx.DOCX_MIME, 'application/zip', 'application/x-zip-compressed', 'application/octet-stream'];
157
+ const rawDocxBody = express.raw({ type: DOCX_BODY_TYPES, limit: docx.MAX_UPLOAD_BYTES, inflate: false });
158
+ function docxBody(req, res, next) {
159
+ rawDocxBody(req, res, (err) => {
160
+ if (!err) return next();
161
+ if (err.type === 'entity.too.large') return refusePage(res, { code: 'upload_too_large', detail: { max_bytes: docx.MAX_UPLOAD_BYTES } });
162
+ return refusePage(res, { code: 'not_a_docx', detail: { reason: 'the upload could not be read' } });
163
+ });
164
+ }
165
+
166
+ function refusePage(res, r) {
167
+ const status = PAGE_REFUSAL_STATUS[r.code] || 400;
168
+ const means = PAGE_REFUSAL_MEANS[r.code];
169
+ return res.fail(r.code, status, { ...(r.detail || {}), ...(means ? { what_this_means: means } : {}) });
170
+ }
171
+
172
+ // The page's reading, which every draft and batch target comes from. Returns
173
+ // null when it has already responded.
174
+ function readingPageOrFail(res, pageId) {
175
+ const readings = readingsState();
176
+ if (!readings.data) {
177
+ res.fail('readings_missing', 503, {
178
+ run: readings.info.run || 'node scripts/gds/page-reader.js',
179
+ what_this_means: 'A draft is resolved against the committed page readings, and this instance has none. Nothing was saved.',
180
+ });
181
+ return null;
182
+ }
183
+ const readingPage = (readings.data.pages || []).find((p) => p && p.id === pageId);
184
+ if (!readingPage) {
185
+ res.fail('page_unread', 503, {
186
+ page_id: pageId,
187
+ what_this_means: 'The page readings do not include this page yet, so there are no lines to resolve a draft against.',
188
+ });
189
+ return null;
190
+ }
191
+ return readingPage;
192
+ }
193
+
194
+ // The page's OPEN round, from the same read the page routes use.
195
+ function openRoundOf(tasks, pageId) {
196
+ const open = (tasks || []).filter((t) => {
197
+ const ref = pages.parseRoundRef(t.source_ref);
198
+ return ref && ref.page_id === pageId && t.status !== 'shipped' && t.status !== 'abandoned';
199
+ });
200
+ return open.length ? open[open.length - 1] : null;
201
+ }
202
+
203
+ // The answer to a repeated submit: the round is in the queue (ready, a frozen
204
+ // batch, nobody holding it) and the caller is its artist, the builder of its
205
+ // latest web claim. Null when that is not the case.
206
+ function alreadySubmitted(round, builderId, pageId) {
207
+ if (round.holder_id != null || round.status !== 'ready' || String(round.artist_id) !== String(builderId)) return null;
208
+ const b = pages.parseBatchBlock(round.description);
209
+ if (!b.ok) return null;
210
+ return {
211
+ ok: true, outcome: 'already_submitted', page_id: pageId,
212
+ round: { task_id: String(round.id), state: 'submitted', status: 'ready' },
213
+ batch: { line_count: b.batch.lines.length, submitted_at: b.batch.submitted_at, reading_hash: b.batch.reading_hash },
214
+ unplaced_tasks: (b.batch.unplaced_tasks || []).map((id) => ({ task_id: String(id) })),
215
+ next: { path: '/copy-desk/next' },
216
+ };
217
+ }
218
+
219
+ // The port's not_held outcome, as the answer the editor shows.
220
+ function refuseNotHeld(res, pageId, r, round) {
221
+ // Nobody holds it and its batch is frozen: it is in the /tweak queue.
222
+ if (!r.holder && round && pages.blockPresence(round.description).batch) {
223
+ return res.fail('page_in_flight', 409, {
224
+ page_id: pageId,
225
+ what_this_means: 'This page has been submitted and is on its way to the site. Its draft is frozen until it ships or is sent back.',
226
+ });
227
+ }
228
+ const who = r.holder ? (r.holder.name || r.holder.login || 'Another builder') : null;
229
+ return res.fail('page_not_held', 409, {
230
+ page_id: pageId,
231
+ holder: r.holder ? { id: r.holder.id, login: r.holder.login, name: r.holder.name } : null,
232
+ what_this_means: who
233
+ ? `${who} holds this page now, so only they can change its draft.`
234
+ : 'You do not hold this page. Claim it again (POST /copy-desk/pages/:pageId/claim) and the draft is still there.',
235
+ });
236
+ }
106
237
 
107
238
  // Load the registry or answer the named "no registry" state. Returns null when
108
239
  // it has already responded, so every handler is `if (!reg) return;`.
@@ -841,6 +972,342 @@ module.exports = function buildCopyDeskRouter() {
841
972
  }
842
973
  });
843
974
 
975
+ // -------------------------------------------------------------------------
976
+ // PUT /copy-desk/pages/:pageId/draft — autosave the artist's draft (task
977
+ // 1004319 / BV2.TW08, ADR 0341 D4).
978
+ //
979
+ // Body: { reading_hash, lines: [{ key, after }] }, the WHOLE draft: every line
980
+ // the artist has changed. The saved draft becomes exactly that (a line sent
981
+ // back at its current text is dropped, so only changed lines are stored).
982
+ // rank: any signed-in builder; only the HOLDER of the page's writing claim
983
+ // (the web claim the page claim took) may save, checked in the
984
+ // lifecycle transaction.
985
+ //
986
+ // Every target is resolved from the page reading by key: section, current
987
+ // text (`before`) and placement are the reading's, never the body's. A body
988
+ // built on an older reading is refused `reading_moved`, whole, since its keys
989
+ // may now mean other lines.
990
+ //
991
+ // Cheap and idempotent: a save that resolves to the draft already stored
992
+ // writes nothing (200 `unchanged`; `updated_at` does not move). The editor
993
+ // debounces; the server only has to make a repeated save free.
994
+ //
995
+ // 200 saved | unchanged with the draft and "N of M rewritten"
996
+ // 409 page_not_held the caller does not hold the page (holder named)
997
+ // 409 no_open_round the page has no round: claim it first
998
+ // 409 reading_moved · 400 unknown_line / line_empty / line_too_long
999
+ // -------------------------------------------------------------------------
1000
+ router.put('/copy-desk/pages/:pageId/draft', api.requireBuilder, async (req, res) => {
1001
+ try {
1002
+ if (validateOrRespond(req, res, DRAFT_BODY_SCHEMA)) return;
1003
+ const found = pageOrFail(req, res);
1004
+ if (!found) return;
1005
+ const { pageId } = found;
1006
+ const lifecycle = pageReadsOrFail(res);
1007
+ if (!lifecycle) return;
1008
+ if (typeof lifecycle.updateHeldTaskDescription !== 'function') {
1009
+ return res.fail('lifecycle_unavailable', 503, { what_this_means: 'This instance cannot save a page draft. Nothing was saved.' });
1010
+ }
1011
+ const readingPage = readingPageOrFail(res, pageId);
1012
+ if (!readingPage) return;
1013
+
1014
+ const resolved = pages.resolveDraft({ readingPage, body: req.body, savedAt: new Date().toISOString() });
1015
+ if (!resolved.ok) return refusePage(res, resolved);
1016
+
1017
+ const round = openRoundOf(await lifecycle.listPageTweakTasks({ pageId }), pageId);
1018
+ if (!round) {
1019
+ return res.fail('no_open_round', 409, {
1020
+ page_id: pageId,
1021
+ what_this_means: 'Nobody is writing this page. Claim it first (POST /copy-desk/pages/:pageId/claim).',
1022
+ });
1023
+ }
1024
+
1025
+ let draft = resolved.draft;
1026
+ const r = await lifecycle.updateHeldTaskDescription({
1027
+ taskId: round.id,
1028
+ builderId: req.builder.id,
1029
+ source: pages.SOURCE,
1030
+ // The same draft again keeps the stored block (saved_at included) and
1031
+ // writes nothing; see pages.saveDraftInto, which the upload shares.
1032
+ compose: (task) => {
1033
+ const s = pages.saveDraftInto(task.description, resolved.draft);
1034
+ if (s.ok) draft = s.draft;
1035
+ return s;
1036
+ },
1037
+ });
1038
+ switch (r.outcome) {
1039
+ case 'saved':
1040
+ case 'unchanged':
1041
+ return res.json({
1042
+ ok: true,
1043
+ outcome: r.outcome,
1044
+ page_id: pageId,
1045
+ round: { task_id: String(round.id), state: 'writing' },
1046
+ draft: { reading_hash: draft.reading_hash, saved_at: draft.saved_at, lines: draft.lines },
1047
+ // The editor's counter, "N of M rewritten · saved" (Editor.dc.html).
1048
+ counter: { changed: draft.lines.length, total: readingPage.lines.length },
1049
+ });
1050
+ case 'not_held':
1051
+ return refuseNotHeld(res, pageId, r, round);
1052
+ case 'refused':
1053
+ return refusePage(res, r.refusal);
1054
+ default:
1055
+ throw new Error(`updateHeldTaskDescription: unexpected outcome ${r.outcome}`);
1056
+ }
1057
+ } catch (err) {
1058
+ log.error({ err }, 'PUT /copy-desk/pages/:pageId/draft failed');
1059
+ if (!res.headersSent) res.fail('copy_page_draft_failed', 500);
1060
+ }
1061
+ });
1062
+
1063
+ // -------------------------------------------------------------------------
1064
+ // GET /copy-desk/pages/:pageId/draft.docx — download the page's words as a
1065
+ // Word document, to work on offline (task 1004320 / BV2.TW09, ADR 0341 D12).
1066
+ //
1067
+ // The file is built from the page reading plus the open round's draft (a line
1068
+ // the artist has rewritten shows its rewrite). A draft saved on an older
1069
+ // reading is not shown, since its keys may now name other lines.
1070
+ // rank: any signed-in builder. The words are the page's own, and the draft is
1071
+ // on a task description every builder can already read; only the
1072
+ // upload is the holder's.
1073
+ // -------------------------------------------------------------------------
1074
+ router.get('/copy-desk/pages/:pageId/draft.docx', api.requireBuilder, async (req, res) => {
1075
+ try {
1076
+ const found = pageOrFail(req, res);
1077
+ if (!found) return;
1078
+ const { page, pageId } = found;
1079
+ const lifecycle = pageReadsOrFail(res);
1080
+ if (!lifecycle) return;
1081
+ const readingPage = readingPageOrFail(res, pageId);
1082
+ if (!readingPage) return;
1083
+ const round = openRoundOf(await lifecycle.listPageTweakTasks({ pageId }), pageId);
1084
+ const d = round ? pages.parseDraftBlock(round.description) : null;
1085
+ const file = docx.composeDownload({ readingPage, title: page.title, draft: d && d.ok ? d.draft : null });
1086
+ res.set('Content-Type', docx.DOCX_MIME);
1087
+ res.set('Content-Disposition', `attachment; filename="${docx.downloadName(pageId)}"`);
1088
+ res.set('Cache-Control', 'no-store');
1089
+ res.send(file);
1090
+ } catch (err) {
1091
+ log.error({ err }, 'GET /copy-desk/pages/:pageId/draft.docx failed');
1092
+ if (!res.headersSent) res.fail('copy_page_docx_failed', 500);
1093
+ }
1094
+ });
1095
+
1096
+ // -------------------------------------------------------------------------
1097
+ // POST /copy-desk/pages/:pageId/draft.docx — upload the Word file back into
1098
+ // the DRAFT (task 1004320 / BV2.TW09, ADR 0341 D12). Never to the site: the
1099
+ // words land in the round's draft block, exactly where the autosave puts
1100
+ // them, through the same port call (holder only) and the same resolver
1101
+ // (every target from the page reading by key, never from the file).
1102
+ //
1103
+ // Body: the .docx bytes (Content-Type: the Word type, application/zip or
1104
+ // application/octet-stream), at most docx.MAX_UPLOAD_BYTES.
1105
+ // rank: any signed-in builder; only the HOLDER of the page's writing claim.
1106
+ //
1107
+ // A MERGE, not a replace: a line is taken from the file only when the artist
1108
+ // changed it in Word (against the text the download showed), so a line they
1109
+ // rewrote in the studio after downloading keeps that rewrite.
1110
+ //
1111
+ // 200 saved | unchanged the draft, the counter, and `merge` (which lines
1112
+ // came from the file, and how they were matched)
1113
+ // 409 structure_changed a line added, dropped or reordered, or a heading
1114
+ // changed; each is named (section, first words)
1115
+ // 409 wrong_page · reading_moved · page_not_held · no_open_round
1116
+ // 413 upload_too_large · 415 not_a_docx
1117
+ // -------------------------------------------------------------------------
1118
+ router.post('/copy-desk/pages/:pageId/draft.docx', api.requireBuilder, docxBody, async (req, res) => {
1119
+ try {
1120
+ const found = pageOrFail(req, res);
1121
+ if (!found) return;
1122
+ const { pageId } = found;
1123
+ const lifecycle = pageReadsOrFail(res);
1124
+ if (!lifecycle) return;
1125
+ if (typeof lifecycle.updateHeldTaskDescription !== 'function') {
1126
+ return res.fail('lifecycle_unavailable', 503, { what_this_means: 'This instance cannot save a page draft. Nothing was saved.' });
1127
+ }
1128
+ const readingPage = readingPageOrFail(res, pageId);
1129
+ if (!readingPage) return;
1130
+
1131
+ const upload = docx.readUpload(Buffer.isBuffer(req.body) ? req.body : null);
1132
+ if (!upload.ok) return refusePage(res, upload);
1133
+ if (upload.page_id !== pageId) {
1134
+ return res.fail('wrong_page', 409, { page_id: pageId, file_page_id: upload.page_id, what_this_means: UPLOAD_WRONG_PAGE_MEANS });
1135
+ }
1136
+ if (upload.reading_hash !== readingPage.reading_hash) {
1137
+ return res.fail('reading_moved', 409, { reading_hash: readingPage.reading_hash || null, file_reading_hash: upload.reading_hash, what_this_means: UPLOAD_READING_MOVED_MEANS });
1138
+ }
1139
+ const mapped = docx.mapUpload({ readingPage, upload });
1140
+ if (!mapped.ok) return refusePage(res, mapped);
1141
+
1142
+ const round = openRoundOf(await lifecycle.listPageTweakTasks({ pageId }), pageId);
1143
+ if (!round) {
1144
+ return res.fail('no_open_round', 409, {
1145
+ page_id: pageId,
1146
+ what_this_means: 'Nobody is writing this page. Claim it first (POST /copy-desk/pages/:pageId/claim), then upload the file.',
1147
+ });
1148
+ }
1149
+
1150
+ const savedAt = new Date().toISOString();
1151
+ let draft = null;
1152
+ let merge = null;
1153
+ const r = await lifecycle.updateHeldTaskDescription({
1154
+ taskId: round.id,
1155
+ builderId: req.builder.id,
1156
+ source: pages.SOURCE,
1157
+ // Merged on the LOCKED task, so the draft the file merges into is the
1158
+ // one stored now, not one read before the lock.
1159
+ compose: (task) => {
1160
+ const prev = pages.parseDraftBlock(task.description);
1161
+ const m = docx.mergeUpload({ readingPage, texts: mapped.texts, baseline: upload.baseline, draft: prev.ok ? prev.draft : null });
1162
+ const resolved = pages.resolveDraft({ readingPage, body: { reading_hash: readingPage.reading_hash, lines: m.lines }, savedAt });
1163
+ if (!resolved.ok) return resolved;
1164
+ const s = pages.saveDraftInto(task.description, resolved.draft);
1165
+ if (s.ok) { draft = s.draft; merge = m; }
1166
+ return s;
1167
+ },
1168
+ });
1169
+ switch (r.outcome) {
1170
+ case 'saved':
1171
+ case 'unchanged':
1172
+ return res.json({
1173
+ ok: true,
1174
+ outcome: r.outcome,
1175
+ page_id: pageId,
1176
+ round: { task_id: String(round.id), state: 'writing' },
1177
+ draft: { reading_hash: draft.reading_hash, saved_at: draft.saved_at, lines: draft.lines },
1178
+ counter: { changed: draft.lines.length, total: readingPage.lines.length },
1179
+ // What the editor shows after an upload ("uploaded · 3 lines from
1180
+ // your doc", Editor.dc.html): the lines the file changed, the draft
1181
+ // lines it left alone, and whether lines were matched by tag or by
1182
+ // position, with or without the file's record of what it showed.
1183
+ merge: { mode: mapped.mode, baseline: !!upload.baseline, from_file: merge.from_file, kept_from_draft: merge.kept_from_draft },
1184
+ });
1185
+ case 'not_held':
1186
+ return refuseNotHeld(res, pageId, r, round);
1187
+ case 'refused':
1188
+ return refusePage(res, r.refusal);
1189
+ default:
1190
+ throw new Error(`updateHeldTaskDescription: unexpected outcome ${r.outcome}`);
1191
+ }
1192
+ } catch (err) {
1193
+ log.error({ err }, 'POST /copy-desk/pages/:pageId/draft.docx failed');
1194
+ if (!res.headersSent) res.fail('copy_page_docx_upload_failed', 500);
1195
+ }
1196
+ });
1197
+
1198
+ // -------------------------------------------------------------------------
1199
+ // POST /copy-desk/pages/:pageId/submit — freeze the draft and queue the page
1200
+ // for /tweak (task 1004319 / BV2.TW08, ADR 0341 D4, D7).
1201
+ //
1202
+ // Body: {} (it freezes the draft already on the task; nothing is taken from
1203
+ // the request). rank: any signed-in builder; the holder only.
1204
+ //
1205
+ // One lifecycle transaction:
1206
+ // * every draft line is re-resolved on the CURRENT page reading; a line the
1207
+ // page no longer shows is refused by name (target_gone), as are an
1208
+ // ambiguous one and a {…} hole added or dropped. Any refusal refuses the
1209
+ // submit, listing the lines, and nothing is frozen;
1210
+ // * a rewrite of an UNPLACED line (and of shared shell text with no
1211
+ // registry row) leaves the batch and files its own engineer task
1212
+ // (source page-tweak-unplaced, backlog), carrying the page, the line and
1213
+ // the wording. The page does not wait for it;
1214
+ // * the rest is frozen into the `page-tweak` block with each line's file,
1215
+ // line and string id from the reading, naming the engineer tasks;
1216
+ // * the artist's web claim is released and the task goes back to `ready`:
1217
+ // the round reads `submitted`, the /tweak queue.
1218
+ //
1219
+ // 200 submitted with the batch, the engineer tasks, and where
1220
+ // "next up" reads (GET /copy-desk/next)
1221
+ // 200 already_submitted a repeated submit by the round's artist
1222
+ // 409 lines_refused · nothing_to_submit · page_not_held · no_open_round
1223
+ // -------------------------------------------------------------------------
1224
+ router.post('/copy-desk/pages/:pageId/submit', api.requireBuilder, async (req, res) => {
1225
+ try {
1226
+ if (validateOrRespond(req, res, SUBMIT_BODY_SCHEMA)) return;
1227
+ const found = pageOrFail(req, res);
1228
+ if (!found) return;
1229
+ const { page, pageId } = found;
1230
+ const lifecycle = pageReadsOrFail(res);
1231
+ if (!lifecycle) return;
1232
+ if (typeof lifecycle.submitPageTweak !== 'function') {
1233
+ return res.fail('lifecycle_unavailable', 503, { what_this_means: 'This instance cannot submit a page. Nothing was submitted.' });
1234
+ }
1235
+ const readingPage = readingPageOrFail(res, pageId);
1236
+ if (!readingPage) return;
1237
+
1238
+ const round = openRoundOf(await lifecycle.listPageTweakTasks({ pageId }), pageId);
1239
+ if (!round) {
1240
+ return res.fail('no_open_round', 409, {
1241
+ page_id: pageId,
1242
+ what_this_means: 'Nobody is writing this page. Claim it first (POST /copy-desk/pages/:pageId/claim).',
1243
+ });
1244
+ }
1245
+ // A double-click: the round's own artist submitting what is already in
1246
+ // the queue gets the same answer, not a refusal.
1247
+ const again = alreadySubmitted(round, req.builder.id, pageId);
1248
+ if (again) return res.json(again);
1249
+
1250
+ const building = proposals.resolveTargetVersion(await lifecycle.listVersions());
1251
+ const submittedAt = new Date().toISOString();
1252
+ let frozen = null;
1253
+ const r = await lifecycle.submitPageTweak({
1254
+ taskId: round.id,
1255
+ builderId: req.builder.id,
1256
+ versionId: building ? building.id : null,
1257
+ freeze: (task) => {
1258
+ const d = pages.parseDraftBlock(task.description);
1259
+ if (!d.ok) return d.code === 'no_block' ? { ok: false, code: 'nothing_to_submit', detail: {} } : d;
1260
+ if (d.draft.page_id !== pageId) return { ok: false, code: 'wrong_page', detail: { page_id: pageId, draft_page_id: d.draft.page_id } };
1261
+ const f = pages.freezeBatch({ readingPage, draft: d.draft, submittedAt });
1262
+ if (!f.ok) return f;
1263
+ const probe = pages.replaceBlock(task.description, pages.FENCES.batch, '');
1264
+ if (!probe.ok) return probe;
1265
+ frozen = f;
1266
+ return {
1267
+ ok: true,
1268
+ unplaced: f.unplaced.map((l) => ({
1269
+ key: l.key,
1270
+ source: pages.UNPLACED_SOURCE,
1271
+ sourceRef: pages.unplacedSourceRef(task.id, l.key),
1272
+ title: pages.unplacedTaskTitle(page, l),
1273
+ description: pages.unplacedTaskBody(page, l, task.id),
1274
+ touches: Array.isArray(page.files) ? page.files : [],
1275
+ })),
1276
+ description: (ids) => pages.replaceBlock(task.description, pages.FENCES.batch,
1277
+ pages.composeBatchBlock({ ...f.batch, unplaced_tasks: ids })).description,
1278
+ };
1279
+ },
1280
+ });
1281
+ switch (r.outcome) {
1282
+ case 'submitted':
1283
+ return res.json({
1284
+ ok: true, outcome: 'submitted', page_id: pageId,
1285
+ round: { task_id: String(r.task.id), state: 'submitted', status: r.task.status },
1286
+ batch: { line_count: frozen.batch.lines.length, submitted_at: submittedAt, reading_hash: frozen.batch.reading_hash },
1287
+ unplaced_tasks: r.unplaced_tasks.map((u) => ({ task_id: u.id, key: u.key, created: u.created })),
1288
+ // "Next up" (the editor's closing card) reads the recommendation.
1289
+ next: { path: '/copy-desk/next' },
1290
+ });
1291
+ case 'not_held': {
1292
+ // A second submit that raced the first waits on the task's row
1293
+ // lock and then finds the claim released: read the round again,
1294
+ // and if the first froze it, this is the same double-click.
1295
+ const now = openRoundOf(await lifecycle.listPageTweakTasks({ pageId }), pageId);
1296
+ const same = now && alreadySubmitted(now, req.builder.id, pageId);
1297
+ if (same) return res.json(same);
1298
+ return refuseNotHeld(res, pageId, r, now || round);
1299
+ }
1300
+ case 'refused':
1301
+ return refusePage(res, r.refusal);
1302
+ default:
1303
+ throw new Error(`submitPageTweak: unexpected outcome ${r.outcome}`);
1304
+ }
1305
+ } catch (err) {
1306
+ log.error({ err }, 'POST /copy-desk/pages/:pageId/submit failed');
1307
+ if (!res.headersSent) res.fail('copy_page_submit_failed', 500);
1308
+ }
1309
+ });
1310
+
844
1311
  return router;
845
1312
  };
846
1313