@murumets-ee/yhikas-sync 0.92.0 → 0.94.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +108 -0
- package/dist/client-J_w85GUr.mjs +2 -0
- package/dist/client-J_w85GUr.mjs.map +1 -0
- package/dist/config-DM3zAAhm.d.mts +244 -0
- package/dist/config-DM3zAAhm.d.mts.map +1 -0
- package/dist/constants-Bmm3P17G.mjs +2 -0
- package/dist/constants-Bmm3P17G.mjs.map +1 -0
- package/dist/en-BOWwWf-X.mjs +2 -0
- package/dist/en-BOWwWf-X.mjs.map +1 -0
- package/dist/et-CGDUOj83.mjs +2 -0
- package/dist/et-CGDUOj83.mjs.map +1 -0
- package/dist/i18n.d.mts +18 -0
- package/dist/i18n.d.mts.map +1 -0
- package/dist/i18n.mjs +2 -0
- package/dist/i18n.mjs.map +1 -0
- package/dist/index.d.mts +360 -900
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +2 -1
- package/dist/index.mjs.map +1 -0
- package/dist/jobs-Di4Z8go9.mjs +2 -0
- package/dist/jobs-Di4Z8go9.mjs.map +1 -0
- package/dist/plugin.d.mts +6 -4
- package/dist/plugin.d.mts.map +1 -1
- package/dist/plugin.mjs +1 -1
- package/dist/plugin.mjs.map +1 -1
- package/dist/rolldown-runtime-DK3Fl9T5.mjs +1 -0
- package/dist/room-page-registry-BFbyGTC7.mjs +2 -0
- package/dist/room-page-registry-BFbyGTC7.mjs.map +1 -0
- package/dist/room-page-registry-CnqXH_Ls.d.mts +974 -0
- package/dist/room-page-registry-CnqXH_Ls.d.mts.map +1 -0
- package/dist/room-pages-adapters-DxFWkFFF.mjs +2 -0
- package/dist/room-pages-adapters-DxFWkFFF.mjs.map +1 -0
- package/dist/room-pages-table-Vd9BqTSl.mjs +2 -0
- package/dist/room-pages-table-Vd9BqTSl.mjs.map +1 -0
- package/dist/room-type-DtMaQbza.mjs +2 -0
- package/dist/room-type-DtMaQbza.mjs.map +1 -0
- package/dist/ru-uzvamDhj.mjs +2 -0
- package/dist/ru-uzvamDhj.mjs.map +1 -0
- package/dist/sync-state-table-CZjRypK0.mjs +2 -0
- package/dist/sync-state-table-CZjRypK0.mjs.map +1 -0
- package/dist/widgets-client.d.mts +27 -0
- package/dist/widgets-client.d.mts.map +1 -0
- package/dist/widgets-client.mjs +3 -0
- package/dist/widgets-client.mjs.map +1 -0
- package/dist/widgets.d.mts +73 -0
- package/dist/widgets.d.mts.map +1 -0
- package/dist/widgets.mjs +2 -0
- package/dist/widgets.mjs.map +1 -0
- package/package.json +41 -7
- package/dist/config-CHVLiXZf.d.mts +0 -77
- package/dist/config-CHVLiXZf.d.mts.map +0 -1
- package/dist/jobs-QeVJDpW9.mjs +0 -2
- package/dist/jobs-QeVJDpW9.mjs.map +0 -1
- package/dist/sync-state-table-BrcoCu-Z.mjs +0 -2
- package/dist/sync-state-table-BrcoCu-Z.mjs.map +0 -1
- package/dist/watchdog-CcOXpzE3.mjs +0 -2
- package/dist/watchdog-CcOXpzE3.mjs.map +0 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"room-page-registry-CnqXH_Ls.d.mts","names":[],"sources":["../src/constants.ts","../src/diff.ts","../src/upstream/wire.ts","../src/projection.ts","../src/apply.ts","../src/entity-client.ts","../src/room-pages-table.ts","../src/room-page-registry.ts"],"mappings":"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"}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import{t as e}from"./rolldown-runtime-DK3Fl9T5.mjs";import{i as t}from"./room-page-registry-BFbyGTC7.mjs";import{and as n,eq as r,inArray as i,is as a}from"drizzle-orm";import{PgColumn as o}from"drizzle-orm/pg-core";const s=e=>typeof e==`string`?e:null,c=e=>typeof e==`number`&&Number.isSafeInteger(e)?e:null;function l(e){let t=s(e.id),n=s(e.code);if(!(t===null||t===``||n===null||n===``))return{id:t,code:n,name:s(e.name)??``,status:s(e.status),capacity:c(e.capacity),placesOccupied:c(e.placesOccupied),totalArea:s(e.totalArea),livingArea:s(e.livingArea),commonArea:s(e.commonArea),monthlyRent:s(e.monthlyRent),discountedMonthlyRent:s(e.discountedMonthlyRent),dailyRent:s(e.dailyRent),currency:s(e.currency)??`EUR`}}function u(e){return e.status===`published`}var d=e({columnOf:()=>f,toRoomPageWriter:()=>g,toRoomTypeReader:()=>h});function f(e,t,n){let r=e[n];if(!a(r,o))throw Error(`yhikas-sync: entity '${t}' has no '${n}' column`);return r}const p=e=>typeof e==`string`?e:null;async function m(e){return e}function h(e,a){return{async published(t,o){let s=o.codes;if(s!==void 0&&s.length===0)return[];let c=e.getTable(),d=f(c,`yhikas_room_type`,`status`),p=s===void 0?r(d,`published`):n(r(d,`published`),i(f(c,`yhikas_room_type`,`code`),[...s])),h=await m(e.findMany({where:p,limit:o.limit,locale:t,...a===void 0?{}:{defaultLocale:a}})),g=[];for(let e of h){let t=l(e);t!==void 0&&u(t)&&g.push(t)}return g},async existingIds(n){let r=new Set,a=f(e.getTable(),`yhikas_room_type`,`id`);for(let o of t([...new Set(n)],100)){let t=await m(e.findMany({where:i(a,o),limit:o.length,withTrashed:!0}));for(let e of t){let t=p(e.id);t!==null&&r.add(t)}}return r}}}function g(e,a,o){if(a.trim().length===0)throw TypeError(`yhikas-sync: defaultLocale must be a non-empty locale code`);return{async findBySlug(t,n){let i=f(e.getTable(),`page`,`slug`);return(await m(e.findMany({where:r(i,t),limit:n,locale:a,defaultLocale:a}))).flatMap(e=>{let t=p(e.id);return t===null?[]:[{id:t,parent:p(e.parent)}]})},async findChildren(s,c){let l=new Map,u=e.getTable(),d=f(u,`page`,`parent`),h=f(u,`page`,`slug`);for(let u of t([...new Set(c)],100)){let t=u.length*4,c=await m(e.findMany({where:n(r(d,s),i(h,u)),limit:t,locale:a,defaultLocale:a,withTrashed:!0}));if(c.length>=t)throw o?.error({parentId:s,slugs:u.length,limit:t},`yhikas-sync: the child-page lookup reached its row ceiling — no room pages created this run`),Error(`yhikas-sync: ${c.length} child pages answered for ${u.length} slugs — refusing to guess which rooms have a page`);for(let e of c){let t=p(e.id),n=p(e.slug);t!==null&&n!==null&&!l.has(n)&&l.set(n,t)}}return l},async createDraft(t){let n=p((await e.create({title:t.title,layout:t.layout,slug:t.slug,slugMode:`manual`,parent:t.parentId,status:`draft`})).id);if(n===null)throw Error(`the page entity answered a create with no id`);return n},async writeLocale(t,n,r){await e.updateForLocale(t,{title:r.title,layout:r.layout,slug:r.slug,slugMode:`manual`},n,{defaultLocale:a})}}}export{u as a,h as i,d as n,l as o,g as r,f as t};
|
|
2
|
+
//# sourceMappingURL=room-pages-adapters-DxFWkFFF.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"room-pages-adapters-DxFWkFFF.mjs","names":["textOf"],"sources":["../src/room-type-view.ts","../src/room-pages-adapters.ts"],"sourcesContent":["/**\n * A synced room type as the room-page drafts and the room facts read it — a\n * narrowed, typed view of one `yhikas_room_type` row.\n *\n * Pure module: no imports at runtime, so the jiti-loaded config can name the\n * type and the tests can build one without a database.\n */\n\n/**\n * One room type, in ONE locale (`name` is that locale's). What\n * `roomPages.draft(room, locale)` receives.\n *\n * Money and area values are the DECIMAL STRINGS the sync stores (see\n * `entities/room-type.ts` — a price never goes through a binary float on its\n * way in). `null` is data: upstream had no value, and nothing invents one.\n */\nexport interface RoomTypeForPage {\n /** The `yhikas_room_type` row id. */\n readonly id: string\n /** The business key — `2ne-1koht`, `L12`. */\n readonly code: string\n /** The name in the locale this view was read in. */\n readonly name: string\n readonly status: string | null\n readonly capacity: number | null\n readonly placesOccupied: number | null\n readonly totalArea: string | null\n readonly livingArea: string | null\n readonly commonArea: string | null\n readonly monthlyRent: string | null\n readonly discountedMonthlyRent: string | null\n readonly dailyRent: string | null\n /** Declared by the sync (`EUR`), not data upstream. */\n readonly currency: string\n}\n\nconst textOf = (value: unknown): string | null => (typeof value === 'string' ? value : null)\nconst integerOf = (value: unknown): number | null =>\n typeof value === 'number' && Number.isSafeInteger(value) ? value : null\n\n/**\n * Narrow one stored row. `undefined` when it carries no id or no code — a row\n * nothing can be keyed on is not a room this package can act for.\n */\nexport function roomTypeForPageFrom(\n row: Readonly<Record<string, unknown>>,\n): RoomTypeForPage | undefined {\n const id = textOf(row.id)\n const code = textOf(row.code)\n if (id === null || id === '' || code === null || code === '') return undefined\n return {\n id,\n code,\n name: textOf(row.name) ?? '',\n status: textOf(row.status),\n capacity: integerOf(row.capacity),\n placesOccupied: integerOf(row.placesOccupied),\n totalArea: textOf(row.totalArea),\n livingArea: textOf(row.livingArea),\n commonArea: textOf(row.commonArea),\n monthlyRent: textOf(row.monthlyRent),\n discountedMonthlyRent: textOf(row.discountedMonthlyRent),\n dailyRent: textOf(row.dailyRent),\n currency: textOf(row.currency) ?? 'EUR',\n }\n}\n\n/** Published — the only state in which a room type is still offered by yhikas-admin. */\nexport function isPublishedRoomType(room: Pick<RoomTypeForPage, 'status'>): boolean {\n return room.status === 'published'\n}\n","/**\n * Adapting framework `AdminClient`s to the structural readers and writers the\n * room pages and the room facts are written against (`room-pages.ts`,\n * `room-facts.ts`).\n *\n * Its own module, like `entity-client.ts`, because this is where the logic\n * meets the framework — and so it is unit-testable with a plain object.\n * Imports nothing from `@murumets-ee/core` at runtime.\n *\n * Every read is ONE query with a `limit`, filtered in SQL through column\n * references from `getTable()` — never raw SQL, never a query per row.\n */\n\nimport { and, eq, inArray, is } from 'drizzle-orm'\nimport { PgColumn } from 'drizzle-orm/pg-core'\nimport type { SyncLogger } from './apply.js'\nimport type { AdminClientLike } from './entity-client.js'\nimport { chunksOf, ROOM_PAGE_READ_CHUNK } from './room-page-registry.js'\nimport type { RoomPageWriter, RoomTypeReader } from './room-pages.js'\nimport { isPublishedRoomType, type RoomTypeForPage, roomTypeForPageFrom } from './room-type-view.js'\n\n/**\n * One column of an entity table, by field name — or a loud error naming it.\n *\n * `getTable()` is typed `PgTableWithColumns<any>`: an entity's columns are\n * generated at runtime, so their names are not in the type. Narrowed here with\n * drizzle's own `is()` (which survives duplicated module instances, unlike\n * `instanceof`) instead of trusting an `any`. For the page entity, `slug`\n * comes from `sluggable()` and `parent` from `pathParent()`; a page entity\n * without either cannot hold room pages, and says so.\n */\nexport function columnOf(\n table: Readonly<Record<string, unknown>>,\n entity: string,\n field: string,\n): PgColumn {\n const column = table[field]\n if (!is(column, PgColumn)) {\n throw new Error(`yhikas-sync: entity '${entity}' has no '${field}' column`)\n }\n return column\n}\n\nconst textOf = (value: unknown): string | null => (typeof value === 'string' ? value : null)\n\n/** Rows as plain records — the DTO of an entity whose fields this package does not know. */\nasync function rowsOf(\n read: Promise<readonly Readonly<Record<string, unknown>>[]>,\n): Promise<readonly Readonly<Record<string, unknown>>[]> {\n return read\n}\n\n/**\n * Published room types, read through the CALLER's client — the job's (as the\n * sync actor) or a render's (as the visitor). An unpublished row — the sync's\n * way of retiring a room — is never answered, here or by the status filter in\n * SQL: both, because the reader is the one place a draft price could leak.\n */\nexport function toRoomTypeReader(client: AdminClientLike, defaultLocale?: string): RoomTypeReader {\n return {\n async published(locale, options) {\n const codes = options.codes\n if (codes !== undefined && codes.length === 0) return []\n const table = client.getTable()\n const status = columnOf(table, 'yhikas_room_type', 'status')\n const where =\n codes === undefined\n ? eq(status, 'published')\n : and(\n eq(status, 'published'),\n inArray(columnOf(table, 'yhikas_room_type', 'code'), [...codes]),\n )\n const rows = await rowsOf(\n client.findMany({\n where,\n limit: options.limit,\n locale,\n ...(defaultLocale !== undefined ? { defaultLocale } : {}),\n }),\n )\n const out: RoomTypeForPage[] = []\n for (const row of rows) {\n const room = roomTypeForPageFrom(row)\n if (room !== undefined && isPublishedRoomType(room)) out.push(room)\n }\n return out\n },\n\n async existingIds(ids) {\n const found = new Set<string>()\n const idColumn = columnOf(client.getTable(), 'yhikas_room_type', 'id')\n for (const chunk of chunksOf([...new Set(ids)], ROOM_PAGE_READ_CHUNK)) {\n const rows = await rowsOf(\n client.findMany({\n where: inArray(idColumn, chunk),\n limit: chunk.length,\n withTrashed: true,\n }),\n )\n for (const row of rows) {\n const id = textOf(row.id)\n if (id !== null) found.add(id)\n }\n }\n return found\n },\n }\n}\n\n/**\n * Rows read per chunk of slugs: a live child and a trashed one may share a\n * slug, so a chunk may answer more rows than slugs. Reaching this ceiling is\n * logged, never silent.\n */\nconst CHILD_ROWS_PER_SLUG = 4\n\n/**\n * The page entity, for the drafts. Writes go through `AdminClient` — hooks,\n * validation, the path guard, audit — as the job's own identity; nothing here\n * elevates.\n */\nexport function toRoomPageWriter(\n client: AdminClientLike,\n defaultLocale: string,\n logger?: SyncLogger,\n): RoomPageWriter {\n if (defaultLocale.trim().length === 0) {\n // Same guard as `toSyncEntityClient`: `updateForLocale` treats `''` as a\n // present default and would write a locale nobody serves.\n throw new TypeError('yhikas-sync: defaultLocale must be a non-empty locale code')\n }\n return {\n async findBySlug(slug, limit) {\n const slugColumn = columnOf(client.getTable(), 'page', 'slug')\n const rows = await rowsOf(\n client.findMany({\n where: eq(slugColumn, slug),\n limit,\n locale: defaultLocale,\n defaultLocale,\n }),\n )\n return rows.flatMap((row) => {\n const id = textOf(row.id)\n return id === null ? [] : [{ id, parent: textOf(row.parent) }]\n })\n },\n\n async findChildren(parentId, slugs) {\n const out = new Map<string, string>()\n const table = client.getTable()\n const parentColumn = columnOf(table, 'page', 'parent')\n const slugColumn = columnOf(table, 'page', 'slug')\n // Every slug is asked about, in chunks — none is dropped past a limit\n // and then mistaken for \"no page yet\".\n for (const chunk of chunksOf([...new Set(slugs)], ROOM_PAGE_READ_CHUNK)) {\n const limit = chunk.length * CHILD_ROWS_PER_SLUG\n const rows = await rowsOf(\n client.findMany({\n where: and(eq(parentColumn, parentId), inArray(slugColumn, chunk)),\n limit,\n locale: defaultLocale,\n defaultLocale,\n // A page an editor moved to Trash still counts as the room's page:\n // re-creating it would undo what they did on purpose.\n withTrashed: true,\n }),\n )\n if (rows.length >= limit) {\n // Fail CLOSED: a slug whose page fell past the ceiling would read as\n // \"no page yet\" and be created twice.\n logger?.error(\n { parentId, slugs: chunk.length, limit },\n 'yhikas-sync: the child-page lookup reached its row ceiling — no room pages created this run',\n )\n throw new Error(\n `yhikas-sync: ${rows.length} child pages answered for ${chunk.length} slugs — refusing to guess which rooms have a page`,\n )\n }\n for (const row of rows) {\n const id = textOf(row.id)\n const slug = textOf(row.slug)\n if (id !== null && slug !== null && !out.has(slug)) out.set(slug, id)\n }\n }\n return out\n },\n\n async createDraft(page) {\n // The payload cast is the established idiom at this seam — see\n // `entity-client.ts`: `InferCreateInput<F>` is keyed on one entity's\n // field map, which a caller holding the site's page definition by NAME\n // cannot be proven to satisfy.\n const created = await client.create({\n title: page.title,\n layout: page.layout,\n slug: page.slug,\n slugMode: 'manual',\n parent: page.parentId,\n status: 'draft',\n } as never)\n const id = textOf((created as Readonly<Record<string, unknown>>).id)\n if (id === null) throw new Error('the page entity answered a create with no id')\n return id\n },\n\n async writeLocale(id, locale, page) {\n // No `status`: the locale inherits the base row's draft.\n await client.updateForLocale(\n id,\n { title: page.title, layout: page.layout, slug: page.slug, slugMode: 'manual' } as never,\n locale,\n { defaultLocale },\n )\n },\n }\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import{t as e}from"./rolldown-runtime-DK3Fl9T5.mjs";import{column as t,defineTable as n}from"@murumets-ee/db";var r=e({MAX_REFUSAL_LENGTH:()=>500,ROOM_PAGE_CLAIM_LEASE_MS:()=>o,ROOM_PAGE_ORIGINS:()=>i,ROOM_PAGE_REFUSAL_KINDS:()=>a,yhikasRoomPagesTable:()=>s});const i=[`created`,`found`,`registered`],a=[`room-type-path`,`path`,`refused`],o=900*1e3,s=n({name:`yhikas_room_pages`,columns:{code:t.varchar({length:190,notNull:!0}),pageId:t.uuid({pgName:`page_id`}),origin:t.varchar({length:16,notNull:!0,default:`created`}),claimToken:t.varchar({length:36,pgName:`claim_token`}),claimedAt:t.timestamp({withTimezone:!0,pgName:`claimed_at`}),lastError:t.varchar({length:500,pgName:`last_error`}),lastErrorKind:t.varchar({length:32,pgName:`last_error_kind`}),lastErrorAt:t.timestamp({withTimezone:!0,pgName:`last_error_at`}),createdAt:t.timestamp({notNull:!0,withTimezone:!0,defaultNow:!0,pgName:`created_at`})},primaryKey:`code`});export{s as a,r as i,i as n,a as r,o as t};
|
|
2
|
+
//# sourceMappingURL=room-pages-table-Vd9BqTSl.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"room-pages-table-Vd9BqTSl.mjs","names":[],"sources":["../src/room-pages-table.ts"],"sourcesContent":["/**\n * `yhikas_room_pages` — one row per room type code the sync has given a page\n * (block-library PR21, D010).\n *\n * ## Why a table, and not \"does a child page with this slug exist?\"\n *\n * The owner's rule is that a room page is created ONCE, ever: never\n * overwritten, never re-created. A slug lookup cannot hold that — an editor\n * who deletes the page, renames its slug, or moves it would get a fresh\n * draft on the next six-hourly run, undoing a decision a person made on\n * purpose. So the decision \"this code has had its page\" is recorded here, and\n * a code in this table is never acted on again, whatever has since happened\n * to the page.\n *\n * ## A row is CLAIMED before its page is created\n *\n * Two runs can overlap — a manual run-now beside the schedule, two workers,\n * a reclaimed stale lease. So a run first inserts the code with no page\n * (`page_id` NULL, `claim_token` its own, `claimed_at` now) in ONE\n * `INSERT … ON CONFLICT … WHERE` statement (`TableClient.tryClaim`); only\n * the run whose insert wins creates the page, and then fills in `page_id`.\n * A claim that never got its page — the run crashed, or the page was refused\n * — lapses after a lease, and the next run takes it over, re-checking by slug\n * first. A row WITH a `page_id` is final.\n *\n * The slug lookup is kept as well, for the other direction: a child page the\n * site seeded by hand (or a migration wrote) before this table knew about it\n * is FOUND and recorded, not duplicated.\n *\n * Infrastructure, not content: `defineTable` + `TableClient`, per CLAUDE.md's\n * two-tier rule, declared on the plugin's `server.tables` so `lumi migrate`\n * sees it.\n */\n\nimport { column, defineTable } from '@murumets-ee/db'\n\n/** How a code came to have its row. */\nexport const ROOM_PAGE_ORIGINS = ['created', 'found', 'registered'] as const\nexport type RoomPageOrigin = (typeof ROOM_PAGE_ORIGINS)[number]\n\n/**\n * Why the last attempt for a code got no page — shown on the dashboard.\n * `room-type-path`: the page's address is still held by the synced room\n * type's old URL row; `path`: held by another document; `refused`: anything\n * else the page entity refused.\n */\nexport const ROOM_PAGE_REFUSAL_KINDS = ['room-type-path', 'path', 'refused'] as const\nexport type RoomPageRefusalKind = (typeof ROOM_PAGE_REFUSAL_KINDS)[number]\n\n/** How long a claim without a page is honoured before another run may take it over. */\nexport const ROOM_PAGE_CLAIM_LEASE_MS = 15 * 60 * 1000\n\n/** The longest stored refusal message. */\nexport const MAX_REFUSAL_LENGTH = 500\n\nexport const yhikasRoomPagesTable = defineTable({\n name: 'yhikas_room_pages',\n columns: {\n /** The room type's business key (`yhikas_room_type.code`, max 190). */\n code: column.varchar({ length: 190, notNull: true }),\n\n /**\n * The page row the code's page is — NULL while the code is only CLAIMED.\n * Not a foreign key: the page may be deleted, and the row must outlive it.\n */\n pageId: column.uuid({ pgName: 'page_id' }),\n\n /**\n * `created` — the sync created it as a draft; `found` — a child page with\n * the code's slug already existed under the room list page; `registered` —\n * a site's own migration recorded it (`recordRoomPage`). Only `created`\n * pages are listed as \"waiting for text and photos\".\n */\n origin: column.varchar({ length: 16, notNull: true, default: 'created' }),\n\n /** The run holding the claim (a random UUID per run); NULL once the row has its page. */\n claimToken: column.varchar({ length: 36, pgName: 'claim_token' }),\n\n /** When the claim was taken. A claim without a page older than the lease lapses. */\n claimedAt: column.timestamp({ withTimezone: true, pgName: 'claimed_at' }),\n\n /** The last refusal for this code, bounded — see `ROOM_PAGE_REFUSAL_KINDS`. Cleared with the page. */\n lastError: column.varchar({ length: MAX_REFUSAL_LENGTH, pgName: 'last_error' }),\n lastErrorKind: column.varchar({ length: 32, pgName: 'last_error_kind' }),\n lastErrorAt: column.timestamp({ withTimezone: true, pgName: 'last_error_at' }),\n\n createdAt: column.timestamp({\n notNull: true,\n withTimezone: true,\n defaultNow: true,\n pgName: 'created_at',\n }),\n },\n primaryKey: 'code',\n})\n"],"mappings":"oQAqCA,MAAa,EAAoB,CAAC,UAAW,QAAS,YAAY,EASrD,EAA0B,CAAC,iBAAkB,OAAQ,SAAS,EAI9D,EAA2B,IAAU,IAKrC,EAAuB,EAAY,CAC9C,KAAM,oBACN,QAAS,CAEP,KAAM,EAAO,QAAQ,CAAE,OAAQ,IAAK,QAAS,EAAK,CAAC,EAMnD,OAAQ,EAAO,KAAK,CAAE,OAAQ,SAAU,CAAC,EAQzC,OAAQ,EAAO,QAAQ,CAAE,OAAQ,GAAI,QAAS,GAAM,QAAS,SAAU,CAAC,EAGxE,WAAY,EAAO,QAAQ,CAAE,OAAQ,GAAI,OAAQ,aAAc,CAAC,EAGhE,UAAW,EAAO,UAAU,CAAE,aAAc,GAAM,OAAQ,YAAa,CAAC,EAGxE,UAAW,EAAO,QAAQ,CAAE,OAAA,IAA4B,OAAQ,YAAa,CAAC,EAC9E,cAAe,EAAO,QAAQ,CAAE,OAAQ,GAAI,OAAQ,iBAAkB,CAAC,EACvE,YAAa,EAAO,UAAU,CAAE,aAAc,GAAM,OAAQ,eAAgB,CAAC,EAE7E,UAAW,EAAO,UAAU,CAC1B,QAAS,GACT,aAAc,GACd,WAAY,GACZ,OAAQ,YACV,CAAC,CACH,EACA,WAAY,MACd,CAAC"}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import{t as e}from"./rolldown-runtime-DK3Fl9T5.mjs";import"./constants-Bmm3P17G.mjs";import{auditable as t,defineEntity as n,field as r,publishable as i}from"@murumets-ee/entity";var a=class extends Error{constructor(e,t,n){super(`'${e}' is written only by the yhikas-admin sync. Refusing ${t} by '${n}': ${t===`translation write`||t===`locale publish change`?`a change here would NOT be reverted on the next sync run — it does not alter sourceHash, so the differ reports the row unchanged and the divergence from yhikas-admin stands permanently, with no alert and no staleness`:`an edit here would be silently reverted on the next sync run, which is worse than no editor at all`}. Change the value in yhikas-admin instead.`),this.name=`YhikasSyncedContentReadOnlyError`}};function o(e,t,n){let r=n?.user?.id;if(r!==`yhikas-sync`)throw new a(e,t,r??`<unattributed>`)}function s(e){return{name:`yhikas-sync:machine-written:${e}`,hooks:{beforeCreate:async(t,n)=>(o(e,`create`,n),t),beforeUpdate:async(t,n,r)=>(o(e,`update`,r),n),beforeDelete:async(t,n)=>{o(e,`delete`,n)}},assertBulkUpdate:()=>{throw new a(e,`bulk update`,`<bulk>`)},assertTranslationWrite:(t,n)=>{o(e,t.action===`locale-status`?`locale publish change`:`translation write`,n)}}}var c=e({VAT_TREATMENTS:()=>u,YHIKAS_ROOM_TYPE_ENTITY:()=>l,YhikasRoomType:()=>d});const l=`yhikas_room_type`,u=[`net`,`gross`],d=n({name:l,scope:`global`,frontend:{path:{et:`/toad/[code]`,en:`/rooms/[code]`},drawnBy:`template`},writtenBy:`machine`,fields:{code:r.text({required:!0,unique:!0,indexed:!0,maxLength:190}),name:r.text({required:!0,translatable:!0}),totalArea:r.text({maxLength:32}),livingArea:r.text({maxLength:32}),commonArea:r.text({maxLength:32}),capacity:r.number({integer:!0}),placesOccupied:r.number({integer:!0}),monthlyRent:r.text({maxLength:32}),discountedMonthlyRent:r.text({maxLength:32}),dailyRent:r.text({maxLength:32}),currency:r.text({required:!0,maxLength:3,default:`EUR`}),vatTreatment:r.select({options:u,required:!0,default:`net`}),hasEnglish:r.boolean({required:!0,default:!1}),sourceHash:r.text({maxLength:64})},behaviors:[t(),i(),s(l)],access:{view:`group.admin`,create:`group.admin`,update:`group.admin`,delete:`group.admin`},admin:{hideFromMenu:!0,group:`yhikas`,label:`Room types`,labelSingular:`Room type`,titleField:`code`,icon:`bed-double`,description:`Synced from yhikas-admin every 6 hours. Read-only here — an edit would be reverted on the next run. Change prices in yhikas-admin.`,defaultSort:`code`,defaultSortDirection:`asc`,disableCreate:!0,hideFromDashboard:!0,hiddenFields:[`sourceHash`],hiddenColumns:[`sourceHash`]}});export{a,c as i,l as n,s as o,d as r,u as t};
|
|
2
|
+
//# sourceMappingURL=room-type-DtMaQbza.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"room-type-DtMaQbza.mjs","names":[],"sources":["../src/entities/guard.ts","../src/entities/room-type.ts"],"sourcesContent":["/**\n * The machine-written guard.\n *\n * PR 05's scope, verbatim: \"synced content is NOT editor-writable — an edit\n * here silently reverts on the next run, which is worse than no editor.\" A\n * field an editor can change and the next sync silently overwrites is a worse\n * affordance than a read-only one, because it looks like it worked.\n *\n * ## Why this is a hook and not `access`\n *\n * `defineEntity`'s `access` is advisory metadata — the firewall\n * (`buildPermissionChecker`) does not read it, and the `admin` role always\n * returns `true` regardless. `admin.hidden` only removes an entity from the\n * nav and is explicitly not a security control. So neither can distinguish the\n * sync from a human administrator; both elevate to the same always-true\n * checker.\n *\n * What CAN distinguish them is the actor identity, which `auditable()` already\n * threads through `BehaviorContext`. The sync runs under a named synthetic\n * actor ({@link YHIKAS_SYNC_ACTOR_ID}), so this hook admits exactly that\n * identity and refuses every other writer — including an administrator\n * clicking through the generic CRUD UI.\n *\n * The `order.ts` precedent uses `ctx.viaInternal` instead. That is deliberately\n * NOT copied here: `viaInternal` is set only by `AdminClient.updateInternal`,\n * and the sync's per-locale writes go through `updateForLocale`, which routes\n * to `update(…, { allowBaseWrite: true })` and never sets the flag. A\n * `viaInternal` gate would therefore block the sync's own writes.\n *\n * ## The translation path — covered since PR 12 (F012)\n *\n * `AdminClient.saveTranslation` and `deleteTranslation` run **no behaviour\n * hooks** — both do a `resolveAuthContext(…, 'update')` permission check and\n * then write directly. And `updateForLocale` reaches `update()` (and therefore\n * the three hooks above) only when the payload leaves non-empty base fields, so\n * a translation-only payload never touched them either.\n *\n * That left this guard covering base rows and nothing else: an administrator\n * who switched the content-locale switcher to `en` and edited a synced document\n * wrote successfully, unseen. And the sync would not revert it, because\n * `sourceHash` is computed from the UPSTREAM projection and never from local\n * state — so the differ read the row as `unchanged` and never wrote it again.\n * The English text diverged from yhikas-admin permanently, with no alert, no\n * staleness and no revert. That is strictly worse than the silent-revert\n * failure this guard was built to prevent.\n *\n * `Behavior.assertTranslationWrite` closes it. It is the sibling of\n * `assertBulkUpdate` and exists for the same reason — a write path that runs no\n * hooks needs its own guard slot — but it carries the resolved\n * `BehaviorContext`, because the question here is WHO is writing rather than\n * WHICH columns.\n *\n * It covers THREE doors, not two, and the third was found by review after the\n * first two were closed: `updateForLocale({ status })` writes\n * `<entity>_locale_status`, a different table again, and a payload of exactly\n * `{ status }` leaves both the translatable and base field sets empty — so\n * neither `saveTranslation` nor `update` runs and neither of their guards\n * fires. Unpublishing one locale of a synced document is the same harm as\n * editing its text and just as permanent: the base row is untouched, so\n * `sourceHash` still matches and the differ reports `unchanged` forever.\n *\n * So `assertSyncActor` now runs on create, update, delete, bulk update, both\n * translation writes, and the per-locale status write.\n *\n * The one honest limit that remains: anything that can mint an elevated context\n * with this actor id can write these rows. That is the whole of the toolkit's\n * elevation surface, and narrowing it further is a firewall question, not an\n * entity one.\n */\n\nimport type { Behavior, BehaviorContext } from '@murumets-ee/entity'\nimport { YHIKAS_SYNC_ACTOR_ID } from '../constants.js'\n\n/** Thrown when a writer other than the sync tries to mutate synced content. */\nexport class YhikasSyncedContentReadOnlyError extends Error {\n constructor(\n entityName: string,\n action:\n | 'create'\n | 'update'\n | 'delete'\n | 'bulk update'\n | 'translation write'\n | 'locale publish change',\n actorId: string,\n ) {\n // The CONSEQUENCE differs by door, and saying the wrong one sends an\n // operator looking for the wrong thing. A base-row edit really would be\n // reverted on the next run. A translation or per-locale publish edit would\n // NOT be: `sourceHash` is computed from the upstream projection and never\n // from local state, so the differ reports `unchanged` and the divergence\n // stands permanently. That asymmetry is the whole reason this guard grew\n // past the base row, so the message must not flatten it back.\n const consequence =\n action === 'translation write' || action === 'locale publish change'\n ? `a change here would NOT be reverted on the next sync run — it does not alter ` +\n `sourceHash, so the differ reports the row unchanged and the divergence from ` +\n `yhikas-admin stands permanently, with no alert and no staleness`\n : `an edit here would be silently reverted on the next sync run, which is worse than ` +\n `no editor at all`\n super(\n `'${entityName}' is written only by the yhikas-admin sync. Refusing ${action} by ` +\n `'${actorId}': ${consequence}. Change the value in yhikas-admin instead.`,\n )\n this.name = 'YhikasSyncedContentReadOnlyError'\n }\n}\n\nfunction assertSyncActor(\n entityName: string,\n action: 'create' | 'update' | 'delete' | 'translation write' | 'locale publish change',\n ctx: BehaviorContext | undefined,\n): void {\n // A missing context is refused, not waved through. An unattributable write to\n // synced content is exactly the case this guard exists for, and defaulting to\n // allow would make the guard depend on every future call path remembering to\n // populate `ctx`.\n const actorId = ctx?.user?.id\n if (actorId !== YHIKAS_SYNC_ACTOR_ID) {\n throw new YhikasSyncedContentReadOnlyError(entityName, action, actorId ?? '<unattributed>')\n }\n}\n\n/**\n * Refuse every create/update/delete not performed by the sync actor.\n *\n * @param entityName Used only in the refusal message — the hook context does\n * not carry the entity name in a shape this can rely on.\n */\nexport function machineWritten<N extends string>(\n entityName: N,\n): Behavior<Record<never, never>, `yhikas-sync:machine-written:${N}`> {\n return {\n name: `yhikas-sync:machine-written:${entityName}` as const,\n hooks: {\n beforeCreate: async (data, ctx) => {\n assertSyncActor(entityName, 'create', ctx)\n return data\n },\n beforeUpdate: async (_id, data, ctx) => {\n assertSyncActor(entityName, 'update', ctx)\n return data\n },\n beforeDelete: async (_id, ctx) => {\n assertSyncActor(entityName, 'delete', ctx)\n },\n },\n // `AdminClient.updateMany` runs NO per-row hooks by design, so the three\n // hooks above cannot see it — a bulk write would walk straight past the\n // guard. `assertBulkUpdate` is the only thing that fires on that path, and\n // `BulkUpdateInfo` is deliberately shape-only: it carries the column set,\n // never an actor. So there is no identity to test, and the honest response\n // is to refuse the path outright. The sync never uses `updateMany`, so\n // that costs nothing and closes the hole rather than leaving it open for\n // want of something to check.\n assertBulkUpdate: () => {\n throw new YhikasSyncedContentReadOnlyError(entityName, 'bulk update', '<bulk>')\n },\n // The translation write path (`saveTranslation` / `deleteTranslation`) runs\n // no hooks either, but UNLIKE the bulk path it carries an actor — so this\n // one tests identity rather than refusing outright. It has to: the sync's\n // own per-locale writes go through `updateForLocale`, which routes a\n // translation-only payload straight to `saveTranslation`. A blanket refusal\n // here would block the sync itself, which is precisely the mistake the\n // `viaInternal` note above records for the base path.\n assertTranslationWrite: (info, ctx) => {\n // The action is named in the refusal because the three doors need\n // different remedies in an operator's head: two are \"you edited the\n // text\", the third is \"you unpublished a language\".\n assertSyncActor(\n entityName,\n info.action === 'locale-status' ? 'locale publish change' : 'translation write',\n ctx,\n )\n },\n }\n}\n","/**\n * `yhikas_room_type` — the local projection of yhikas-admin's `room_type`.\n *\n * ## Decimals are text, and that is not a style choice\n *\n * `field.number()` maps to Postgres `doublePrecision` unless `integer: true`\n * — a float8. Every money and area value here arrives from `pg` as a STRING\n * (`numeric` serialization) and is stored as one, because routing a price\n * through a binary float introduces rounding that nothing downstream can undo.\n * `capacity` and `placesOccupied` are genuine integers and use `field.number`.\n *\n * ## Nulls are data\n *\n * Upstream passes nulls through untouched and says why: \"so the site can\n * decide how to render a missing price rather than displaying a fabricated\n * zero.\" None of the price fields is `required`, and the sync substitutes no\n * defaults — a missing price stays missing all the way to the renderer.\n *\n * ## What this side declares\n *\n * There is no currency column, no VAT column and no period column upstream;\n * EUR-net-per-month is a convention held in column NAMING. `currency` and\n * `vatTreatment` are therefore real columns written with constants, so a\n * mis-entered source value renders as a visibly wrong number against a\n * declared unit rather than as a plausible one.\n *\n * ## Security\n *\n * `access` below is advisory metadata — the firewall does not read it and the\n * `admin` role bypasses it unconditionally. The real controls are (a)\n * deny-by-default: `buildInitialRoleDefinitions` grants no non-admin role any\n * permission on this entity, and (b) `machineWritten`, which refuses every\n * writer that is not the sync actor, including an administrator in the CRUD\n * UI. `admin.disableCreate` is a UI affordance, never a gate.\n */\n\nimport { auditable, defineEntity, field, publishable } from '@murumets-ee/entity'\nimport { DECLARED_CURRENCY, DECLARED_VAT_TREATMENT } from '../constants.js'\nimport { machineWritten } from './guard.js'\n\nexport const YHIKAS_ROOM_TYPE_ENTITY = 'yhikas_room_type'\n\n/** VAT treatments this projection can express. `net` = the amount excludes VAT. */\nexport const VAT_TREATMENTS = ['net', 'gross'] as const\n\nexport const YhikasRoomType = defineEntity({\n name: YHIKAS_ROOM_TYPE_ENTITY,\n scope: 'global',\n // A room has its own page, at the URLs the site already uses, and that page\n // is the stored \"room detail\" template drawn with this row (block-library\n // D009) — the entity has no block field, because nobody authors a room here.\n // `code` is unique, so two rooms can never claim one path. A sync write is\n // still refused if ANOTHER document already holds that path (a page authored\n // at `/toad/<code>`) or it falls under a reserved prefix — being routable\n // puts the path guard in front of every write of this row.\n //\n // `drawnBy` also NARROWS what the public content API serves for a room to its\n // id plus the values the site's detail bindings name; see `EntityFrontend`.\n frontend: { path: { et: '/toad/[code]', en: '/rooms/[code]' }, drawnBy: 'template' },\n // Stated so the toolkit can SEE what `machineWritten()` below enforces: the\n // canvas shows a room's values read-only and says why, instead of accepting\n // an edit the guard then refuses.\n writtenBy: 'machine',\n fields: {\n /**\n * The business key. `room_type.code` is `notNull().unique()` upstream and\n * is what identity is keyed on — never the serial `id`, which is an\n * implementation detail of the other system and would make this system's\n * content depend on the other's insert order.\n */\n code: field.text({ required: true, unique: true, indexed: true, maxLength: 190 }),\n\n /** Translatable: `et` on the base row, `en` in `yhikas_room_type_translations`. */\n name: field.text({ required: true, translatable: true }),\n\n /** Areas in m², as strings. See the note on decimals above. */\n totalArea: field.text({ maxLength: 32 }),\n livingArea: field.text({ maxLength: 32 }),\n commonArea: field.text({ maxLength: 32 }),\n\n capacity: field.number({ integer: true }),\n placesOccupied: field.number({ integer: true }),\n\n /**\n * Period is carried in the field NAME rather than in a `period` column,\n * deliberately: one row holds both a monthly and a daily rate, so a single\n * period column could only ever be wrong about one of them.\n */\n monthlyRent: field.text({ maxLength: 32 }),\n discountedMonthlyRent: field.text({ maxLength: 32 }),\n dailyRent: field.text({ maxLength: 32 }),\n\n /** Declared by this side; not data upstream. */\n currency: field.text({ required: true, maxLength: 3, default: DECLARED_CURRENCY }),\n vatTreatment: field.select({\n options: VAT_TREATMENTS,\n required: true,\n default: DECLARED_VAT_TREATMENT,\n }),\n\n /**\n * Whether the upstream row carried a non-empty English name. A convenience\n * for a renderer building an English price sheet — NOT the control. The\n * control is per-locale publish status (D015): when this is `false` the\n * `en` locale is unpublished, so the public read path cannot serve it\n * whether or not anyone reads this flag.\n */\n hasEnglish: field.boolean({ required: true, default: false }),\n\n /**\n * Stable hash of the normalized upstream row. `room_type` carries no\n * timestamp of any kind — no `created_at`, no `updated_at`, no version —\n * so this is the only available change signal, and without it every run\n * would rewrite all ~25 rows and fill the audit log with ~100 no-op\n * updates a day.\n */\n sourceHash: field.text({ maxLength: 64 }),\n },\n behaviors: [auditable(), publishable(), machineWritten(YHIKAS_ROOM_TYPE_ENTITY)],\n access: {\n view: 'group.admin',\n create: 'group.admin',\n update: 'group.admin',\n delete: 'group.admin',\n },\n admin: {\n // Out of the admin menu (CLAUDE.md \"Screens first\"): nothing a person does\n // goes through this raw, machine-written table. The entity stays reachable\n // — entity API, content API, collection sources, live sources and a direct\n // URL all still read it; only the sidebar link is gone.\n hideFromMenu: true,\n group: 'yhikas',\n label: 'Room types',\n labelSingular: 'Room type',\n titleField: 'code',\n icon: 'bed-double',\n description:\n 'Synced from yhikas-admin every 6 hours. Read-only here — an edit would be reverted on the next run. Change prices in yhikas-admin.',\n defaultSort: 'code',\n defaultSortDirection: 'asc',\n disableCreate: true,\n hideFromDashboard: true,\n hiddenFields: ['sourceHash'],\n hiddenColumns: ['sourceHash'],\n },\n})\n"],"mappings":"mLA0EA,IAAa,EAAb,cAAsD,KAAM,CAC1D,YACE,EACA,EAOA,EACA,CAeA,MACE,IAAI,EAAW,uDAAuD,EAAO,OACvE,EAAQ,KARd,IAAW,qBAAuB,IAAW,wBACzC,2NAGA,qGAI2B,4CACjC,EACA,KAAK,KAAO,kCACd,CACF,EAEA,SAAS,EACP,EACA,EACA,EACM,CAKN,IAAM,EAAU,GAAK,MAAM,GAC3B,GAAI,IAAA,cACF,MAAM,IAAI,EAAiC,EAAY,EAAQ,GAAW,gBAAgB,CAE9F,CAQA,SAAgB,EACd,EACoE,CACpE,MAAO,CACL,KAAM,+BAA+B,IACrC,MAAO,CACL,aAAc,MAAO,EAAM,KACzB,EAAgB,EAAY,SAAU,CAAG,EAClC,GAET,aAAc,MAAO,EAAK,EAAM,KAC9B,EAAgB,EAAY,SAAU,CAAG,EAClC,GAET,aAAc,MAAO,EAAK,IAAQ,CAChC,EAAgB,EAAY,SAAU,CAAG,CAC3C,CACF,EASA,qBAAwB,CACtB,MAAM,IAAI,EAAiC,EAAY,cAAe,QAAQ,CAChF,EAQA,wBAAyB,EAAM,IAAQ,CAIrC,EACE,EACA,EAAK,SAAW,gBAAkB,wBAA0B,oBAC5D,CACF,CACF,CACF,CACF,oFCxIA,MAAa,EAA0B,mBAG1B,EAAiB,CAAC,MAAO,OAAO,EAEhC,EAAiB,EAAa,CACzC,KAAM,EACN,MAAO,SAWP,SAAU,CAAE,KAAM,CAAE,GAAI,eAAgB,GAAI,eAAgB,EAAG,QAAS,UAAW,EAInF,UAAW,UACX,OAAQ,CAON,KAAM,EAAM,KAAK,CAAE,SAAU,GAAM,OAAQ,GAAM,QAAS,GAAM,UAAW,GAAI,CAAC,EAGhF,KAAM,EAAM,KAAK,CAAE,SAAU,GAAM,aAAc,EAAK,CAAC,EAGvD,UAAW,EAAM,KAAK,CAAE,UAAW,EAAG,CAAC,EACvC,WAAY,EAAM,KAAK,CAAE,UAAW,EAAG,CAAC,EACxC,WAAY,EAAM,KAAK,CAAE,UAAW,EAAG,CAAC,EAExC,SAAU,EAAM,OAAO,CAAE,QAAS,EAAK,CAAC,EACxC,eAAgB,EAAM,OAAO,CAAE,QAAS,EAAK,CAAC,EAO9C,YAAa,EAAM,KAAK,CAAE,UAAW,EAAG,CAAC,EACzC,sBAAuB,EAAM,KAAK,CAAE,UAAW,EAAG,CAAC,EACnD,UAAW,EAAM,KAAK,CAAE,UAAW,EAAG,CAAC,EAGvC,SAAU,EAAM,KAAK,CAAE,SAAU,GAAM,UAAW,EAAG,QAAA,KAA2B,CAAC,EACjF,aAAc,EAAM,OAAO,CACzB,QAAS,EACT,SAAU,GACV,QAAA,KACF,CAAC,EASD,WAAY,EAAM,QAAQ,CAAE,SAAU,GAAM,QAAS,EAAM,CAAC,EAS5D,WAAY,EAAM,KAAK,CAAE,UAAW,EAAG,CAAC,CAC1C,EACA,UAAW,CAAC,EAAU,EAAG,EAAY,EAAG,EAAe,CAAuB,CAAC,EAC/E,OAAQ,CACN,KAAM,cACN,OAAQ,cACR,OAAQ,cACR,OAAQ,aACV,EACA,MAAO,CAKL,aAAc,GACd,MAAO,SACP,MAAO,aACP,cAAe,YACf,WAAY,OACZ,KAAM,aACN,YACE,qIACF,YAAa,OACb,qBAAsB,MACtB,cAAe,GACf,kBAAmB,GACnB,aAAc,CAAC,YAAY,EAC3B,cAAe,CAAC,YAAY,CAC9B,CACF,CAAC"}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
var e={YhikasSync:{facts:{sourceLabel:`Данные комнаты`},provenance:{recordRoomType:`тип комнаты`,cadenceEvery6Hours:`каждые 6 часов`,cadenceEvery12Hours:`каждые 12 часов`,cadenceHourly:`каждый час`,cadenceDaily:`каждый день`},roomPages:{title:`Страницы комнат`,waiting:`{count, plural, one {# новая комната ждёт текста и фотографий} few {# новые комнаты ждут текста и фотографий} many {# новых комнат ждут текста и фотографий} other {# новой комнаты ждут текста и фотографий}}`,open:`Открыть`,retired:`Комнаты {code} больше нет в yhikas-admin — снять страницу с публикации?`,unpublish:`Снять с публикации`,unpublishing:`Снимаем…`,unpublishFailed:`Не снято с публикации: {failures}. Ничего не обновляется, пока не сняты все языки — попробуйте ещё раз.`,retiredAsk:`Комнаты {code} больше нет в yhikas-admin, но её страница всё ещё опубликована. Снять её должен тот, у кого есть право публиковать страницы.`,refused:`Комната {code} не получила страницу: {reason}`,refusedRoomTypePath:`Комната {code} не получила страницу: её адрес всё ещё занят старым URL типа комнаты ({reason}). Удалите строки yhikas_room_type из индекса адресов (toolkit_paths); следующая синхронизация создаст страницу.`}}};export{e as default};
|
|
2
|
+
//# sourceMappingURL=ru-uzvamDhj.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ru-uzvamDhj.mjs","names":[],"sources":["../src/messages/ru.json"],"sourcesContent":[""],"mappings":""}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import{t as e}from"./rolldown-runtime-DK3Fl9T5.mjs";import"./constants-Bmm3P17G.mjs";import{o as t,r as n}from"./room-type-DtMaQbza.mjs";import{auditable as r,defineEntity as i,field as a,publishable as o}from"@murumets-ee/entity";import{column as s,defineTable as c}from"@murumets-ee/db";const l=new Set([`ateljee`,`atelier`,`harjutus-prooviruum`,`practice room`]);function u(e){return!l.has(e.name.trim().toLowerCase())}var d=class extends Error{constructor(e){super(e),this.name=`YhikasSyncConfigError`}};function f(e,t){if(!Number.isSafeInteger(e)||e<=0)throw new d(`${t} must be a positive integer, got ${e}`);return e}function p(e,t){if(!Number.isFinite(e)||e<=0)throw new d(`${t} must be a finite positive number, got ${e}`);return e}function m(e={},t=process.env){let n=e.maxRetireFraction??.5;if(!Number.isFinite(n)||n<=0||n>1)throw new d(`maxRetireFraction must be in (0, 1], got ${n}. A value of 1 disables the fraction rule; the empty-snapshot rule still applies.`);let r=e.baseUrl??t.YHIKAS_ADMIN_BASE_URL;return Object.freeze({baseUrl:r&&r.length>0?r:void 0,schedule:e.schedule??`@every 6h`,watchdogSchedule:e.watchdogSchedule??`@every 1h`,staleAfterMs:p(e.staleAfterMs??864e5,`staleAfterMs`),requestTimeoutMs:p(e.requestTimeoutMs??15e3,`requestTimeoutMs`),maxRowsPerResource:f(e.maxRowsPerResource??500,`maxRowsPerResource`),maxRetireFraction:n,minRowsForRetireFloor:f(e.minRowsForRetireFloor??3,`minRowsForRetireFloor`),enabled:e.enabled??!0,detailPages:h(e.detailPages),roomPages:y(e.roomPages)})}function h(e){if(e===void 0||e.drawnBy===`template`)return Object.freeze({drawnBy:`template`});if(e.drawnBy!==`site`)throw new d(`detailPages.drawnBy must be 'template' or 'site', got ${String(e.drawnBy)}.`);let t=typeof e.reason==`string`?e.reason.trim():``;if(t===``)throw new d("detailPages: { drawnBy: 'site' } needs a `reason` — why this site draws room and document pages by hand instead of with their detail templates.");return Object.freeze({drawnBy:`site`,reason:t})}const g=/^[a-z0-9-]+$/,_=/^[a-z][a-z0-9_]{0,62}$/;function v(e,t){if(typeof e!=`string`||e.length>300)throw new d(`${t} must be an https URL of at most 300 characters.`);let n;try{n=new URL(e)}catch{throw new d(`${t} must be an absolute https URL, got '${e}'.`)}if(n.protocol!==`https:`||n.username!==``||n.password!==``||n.search!==``||n.hash!==``)throw new d(`${t} must be a plain https URL — no credentials, query or fragment — got '${e}'.`);return`${n.origin}${n.pathname}`.replace(/\/+$/,``)}function y(e){if(e===void 0)return;let t=e.entity??`page`;if(!_.test(t))throw new d(`roomPages.entity must be an entity name, got '${t}'.`);if(typeof e.parentSlug!=`string`||!g.test(e.parentSlug))throw new d(`roomPages.parentSlug must be a page slug (lowercase letters, digits, '-'), got '${String(e.parentSlug)}'.`);if(typeof e.draft!=`function`)throw new d(`roomPages.draft must be a function (room, locale) => { title, layout } — the new page.`);if(e.offered!==void 0&&typeof e.offered!=`function`)throw new d(`roomPages.offered must be a function (room) => boolean.`);return Object.freeze({entity:t,parentSlug:e.parentSlug,offered:e.offered??u,draft:e.draft,adminUrl:e.adminUrl===void 0?void 0:v(e.adminUrl,`roomPages.adminUrl`)})}const b=`yhikas_legal_document`,x=i({name:b,scope:`global`,frontend:{path:{et:`/dokumendid/[sourceSlug]`,en:`/documents/[sourceSlug]`},drawnBy:`template`},writtenBy:`machine`,fields:{type:a.text({required:!0,unique:!0,indexed:!0,maxLength:190}),title:a.text({required:!0,translatable:!0}),sourceSlug:a.text({required:!0,indexed:!0,maxLength:190}),body:a.richtext({required:!0,translatable:!0}),order:a.number({integer:!0,required:!0,default:0}),hasEnglish:a.boolean({required:!0,default:!1}),sourceHash:a.text({maxLength:64})},behaviors:[r(),o(),t(b)],access:{view:`group.admin`,create:`group.admin`,update:`group.admin`,delete:`group.admin`},admin:{hideFromMenu:!0,group:`yhikas`,label:`Legal documents`,labelSingular:`Legal document`,titleField:`type`,icon:`file-text`,description:`Synced from yhikas-admin every 6 hours. Read-only here — an edit would be reverted on the next run. Change the text in yhikas-admin.`,defaultSort:`order`,defaultSortDirection:`asc`,disableCreate:!0,hideFromDashboard:!0,hiddenFields:[`sourceHash`],hiddenColumns:[`sourceHash`]}}),S=`yhikas_site_info`,C=i({name:S,kind:`singleton`,scope:`global`,fields:{key:a.text({required:!0,unique:!0,indexed:!0,maxLength:32}),receptionHours:a.text({translatable:!0,maxLength:500}),notices:a.json({translatable:!0}),hasEnglish:a.boolean({default:!1,required:!0}),sourceHash:a.text({maxLength:64})},behaviors:[r(),o(),t(S)],access:{view:`group.admin`,create:`group.admin`,update:`group.admin`,delete:`group.admin`},admin:{hideFromMenu:!0,group:`yhikas`,label:`Site info`,titleField:`key`,disableCreate:!0,hideFromDashboard:!0,hiddenFields:[`sourceHash`],hiddenColumns:[`sourceHash`]}}),w=[n,x,C];function T(e){let{detailPages:t,roomPages:r}=e,i=(e,t)=>{let n=e.frontend?.path;if(n===void 0)throw Error(`yhikas-sync: ${e.name} declares no frontend path to draw by hand`);return{...e,frontend:{path:n,drawnBy:`site`,reason:t}}},a=t.drawnBy===`template`?x:i(x,t.reason);return r===void 0?{roomType:t.drawnBy===`template`?n:i(n,t.reason),legalDocument:a}:{roomType:E(n),legalDocument:a}}function E(e){let{frontend:t,...n}=e;return n}function D(e){if(e.detailPages.drawnBy===`template`&&e.roomPages===void 0)return w;let{roomType:t,legalDocument:n}=T(e);return[t,n,C]}var O=e({yhikasSyncStateTable:()=>k});const k=c({name:`yhikas_sync_state`,columns:{resource:s.varchar({length:64,notNull:!0}),firstSeenAt:s.timestamp({notNull:!0,withTimezone:!0,defaultNow:!0,pgName:`first_seen_at`}),lastAttemptAt:s.timestamp({withTimezone:!0,pgName:`last_attempt_at`}),lastSuccessAt:s.timestamp({withTimezone:!0,pgName:`last_success_at`}),lastError:s.varchar({length:1024,pgName:`last_error`}),lastCreated:s.integer({notNull:!0,default:0,pgName:`last_created`}),lastUpdated:s.integer({notNull:!0,default:0,pgName:`last_updated`}),lastRetired:s.integer({notNull:!0,default:0,pgName:`last_retired`}),lastUnchanged:s.integer({notNull:!0,default:0,pgName:`last_unchanged`})},primaryKey:`resource`});export{D as a,b as c,d,v as f,u as h,T as i,x as l,l as m,k as n,S as o,m as p,w as r,C as s,O as t,g as u};
|
|
2
|
+
//# sourceMappingURL=sync-state-table-CZjRypK0.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sync-state-table-CZjRypK0.mjs","names":[],"sources":["../src/room-offers.ts","../src/config.ts","../src/entities/legal-document.ts","../src/entities/site-info.ts","../src/entities/index.ts","../src/sync-state-table.ts"],"sourcesContent":["/**\n * Which synced room types are room OFFERS — rooms a visitor can rent — and\n * which are units yhikas-admin merely prices.\n *\n * Moved here from the yhikas site's `lib/room-prices.ts` (block-library PR21),\n * because two things in this package now ask the question: the room-page\n * drafts (`roomPages`, one page per offered room, never one per unit) and the\n * `yhikasRoomFacts()` picker. THE one list — a site that needs a different\n * rule passes `roomPages.offered`, rather than keeping a second copy.\n *\n * Pure module: no imports, no I/O. Reached from the jiti-loaded plugin entry.\n */\n\n/**\n * Names of the units yhikas-admin prices by area — ateliers and practice\n * rooms. They are room TYPES upstream but not room OFFERS: the rooms list\n * leaves them out and they never set a \"from\" price. Upstream has no column\n * that says which is which, so the name is the only signal; both locales are\n * listed because a row may be read in either.\n */\nexport const AREA_PRICED_UNIT_NAMES: ReadonlySet<string> = new Set([\n 'ateljee',\n 'atelier',\n 'harjutus-prooviruum',\n 'practice room',\n])\n\n/** The shape {@link isRoomOffer} and `roomPages.offered` are asked about. */\nexport interface RoomOfferCandidate {\n readonly code: string\n readonly name: string\n}\n\n/**\n * True for a room type a visitor can rent as a room — not an area-priced unit.\n * The default for `roomPages.offered` and for `yhikasRoomFacts({ offered })`.\n */\nexport function isRoomOffer(room: RoomOfferCandidate): boolean {\n return !AREA_PRICED_UNIT_NAMES.has(room.name.trim().toLowerCase())\n}\n\n/** The predicate a caller passes to decide what is offered. */\nexport type RoomOfferPredicate = (room: RoomOfferCandidate) => boolean\n","/**\n * Plugin configuration — pure resolution, no I/O, no `server-only`.\n *\n * There is deliberately no `defaultLocale` knob either. The default locale is\n * read from `@murumets-ee/content`'s `resolveI18nConfig(app)` at job-run time,\n * because a configured copy could silently disagree with the app's actual\n * default — and the consequences run both ways: `updateForLocale` would take\n * its default-locale branch for what this package believed was the secondary\n * locale and overwrite the base row with the wrong language, while no\n * translation was written for the real one. Neither write fails. A knob whose\n * only reachable states are \"correct but redundant\" and \"wrong and silent\" is a\n * trap, not a setting.\n *\n * The bearer credential is deliberately NOT part of this object. Resolved\n * config is stored in the framework's `PluginConfigRegistry`, which is a\n * process-global any plugin can read; a secret belongs in the environment and\n * is read at job-run time instead (`resolveCredential`). This mirrors\n * `@murumets-ee/merit` D003 — the key never lands in a registry or the DB.\n */\n\nimport {\n BASE_URL_ENV_VAR,\n DEFAULT_MAX_RETIRE_FRACTION,\n DEFAULT_MAX_ROWS_PER_RESOURCE,\n DEFAULT_MIN_ROWS_FOR_RETIRE_FLOOR,\n DEFAULT_REQUEST_TIMEOUT_MS,\n DEFAULT_STALE_AFTER_MS,\n DEFAULT_SYNC_SCHEDULE,\n DEFAULT_WATCHDOG_SCHEDULE,\n} from './constants.js'\nimport { isRoomOffer, type RoomOfferPredicate } from './room-offers.js'\nimport type { RoomTypeForPage } from './room-type-view.js'\n\n/**\n * One block of a room page's draft tree, structurally — the stored shape a\n * `field.blockTree()` holds (`{ id, type, props, traits, children }`). Loose\n * in `props` on purpose: a block's props are its own fields, which only the\n * site's block definitions know. The tree is validated where every tree is,\n * by the page entity's write path.\n */\nexport interface RoomPageBlock {\n readonly id: string\n readonly type: string\n readonly props: Readonly<Record<string, unknown>>\n readonly traits?: Readonly<Record<string, unknown>>\n readonly children?: readonly RoomPageBlock[]\n}\n\n/** A new room page's title and block tree, in one locale. */\nexport interface RoomPageDraft {\n readonly title: string\n readonly layout: readonly RoomPageBlock[]\n}\n\n/**\n * Every offered room is an ordinary CMS page (block-library D010), a child of\n * the room list page, edited on the canvas; its prices are a live facts block\n * bound to `yhikasRoomFacts()`.\n *\n * Setting this does three things:\n *\n * 1. The room type entity declares NO frontend path — a room is not routable\n * from its synced row; its page owns `/<parentSlug>/<code>`. (Legal\n * documents keep following `detailPages`, which no longer affects room\n * types.)\n * 2. After every successful room-types sync, a DRAFT page is created for each\n * published, offered room type that has none — once, ever (see\n * `yhikas_room_pages`): never published, never overwritten, never\n * re-created after an editor empties or deletes it.\n * 3. The admin dashboard shows the room pages waiting for text and photos,\n * and the published room pages whose room yhikas-admin has retired.\n */\nexport interface YhikasRoomPagesConfig {\n /** The page entity. Default `'page'`. */\n readonly entity?: string\n /** The room list page's slug, in the default locale — room pages are its children. `'toad'` on yhikas. */\n readonly parentSlug: string\n /**\n * Which room types get a page. Default {@link isRoomOffer}: every room type\n * except the area-priced units (ateliers, practice rooms). Asked with the\n * default-locale name.\n */\n readonly offered?: RoomOfferPredicate\n /** The new DRAFT page's title and block tree, per locale. Called once per locale, when the page is created. */\n readonly draft: (room: RoomTypeForPage, locale: string) => RoomPageDraft\n /**\n * yhikas-admin's PUBLIC base URL (`https:`), for the \"change it in\n * yhikas-admin\" link beside a room's facts. Not `baseUrl` — that is the\n * Docker-internal origin the sync pulls from, which no browser can reach.\n */\n readonly adminUrl?: string\n}\n\n/** {@link YhikasRoomPagesConfig}, validated and defaulted. */\nexport interface ResolvedRoomPagesConfig {\n readonly entity: string\n readonly parentSlug: string\n readonly offered: RoomOfferPredicate\n readonly draft: (room: RoomTypeForPage, locale: string) => RoomPageDraft\n readonly adminUrl: string | undefined\n}\n\nexport interface YhikasSyncConfig {\n /**\n * Origin of the yhikas-admin deployment, e.g. `http://yhikas-admin:3000`\n * over the Docker-internal network. Falls back to `YHIKAS_ADMIN_BASE_URL`.\n */\n baseUrl?: string\n /** Cron/interval expression for the pull. Default `@every 6h` (D017). */\n schedule?: string\n /** Cron/interval expression for the staleness watchdog. Default `@every 1h`. */\n watchdogSchedule?: string\n /** Alert when the newest successful sync is older than this. Default 24h. */\n staleAfterMs?: number\n /** Per-request deadline. The queue has NO per-job timeout (R012 §3). Default 15s. */\n requestTimeoutMs?: number\n /** Refuse a snapshot larger than this rather than truncating it (D016). */\n maxRowsPerResource?: number\n /** Refuse a run retiring more than this fraction of published rows. Default 0.5. */\n maxRetireFraction?: number\n /** The fraction rule only applies once at least this many rows are published. Default 3. */\n minRowsForRetireFloor?: number\n /**\n * Explicit opt-out. An unconfigured sync is a BROKEN sync, and a broken sync\n * must be visibly broken (S7) — so an absent credential fails the job loudly\n * rather than quietly skipping registration. Set this to `false` when the\n * install genuinely has no yhikas-admin to talk to.\n */\n enabled?: boolean\n /**\n * Who draws a room's and a legal document's detail page.\n *\n * The default is the toolkit's: each entity's stored detail template\n * (`drawnBy: 'template'`, block-library D009), which also narrows the public\n * content API to what the template draws. A site that draws those pages with\n * hand-built routes says so here, WITH a reason — the opt-out is reported at\n * start-up (`handBuiltDetailPages`), so it is a recorded decision rather\n * than a default (block-library PR19).\n *\n * @example\n * yhikasSync({ detailPages: { drawnBy: 'site', reason: 'Hand-built Afišš room and document pages (owner, 2026-10-02)' } })\n */\n detailPages?:\n | { readonly drawnBy: 'template' }\n | { readonly drawnBy: 'site'; readonly reason: string }\n /**\n * Rooms as ordinary CMS pages (block-library PR21, D010). With this set,\n * `detailPages` applies to legal documents only. See\n * {@link YhikasRoomPagesConfig}.\n *\n * @example\n * yhikasSync({\n * roomPages: {\n * parentSlug: 'toad',\n * adminUrl: 'https://admin.yhikas.ee',\n * draft: (room, locale) => ({ title: room.name, layout: roomPageTree(room, locale) }),\n * },\n * })\n */\n roomPages?: YhikasRoomPagesConfig\n}\n\nexport interface ResolvedYhikasSyncConfig {\n readonly baseUrl: string | undefined\n readonly schedule: string\n readonly watchdogSchedule: string\n readonly staleAfterMs: number\n readonly requestTimeoutMs: number\n readonly maxRowsPerResource: number\n readonly maxRetireFraction: number\n readonly minRowsForRetireFloor: number\n readonly enabled: boolean\n readonly detailPages:\n | { readonly drawnBy: 'template' }\n | { readonly drawnBy: 'site'; readonly reason: string }\n /** `undefined` when the site does not keep its rooms as pages. */\n readonly roomPages: ResolvedRoomPagesConfig | undefined\n}\n\n/** Thrown at plugin construction for a structurally invalid config. */\nexport class YhikasSyncConfigError extends Error {\n constructor(message: string) {\n super(message)\n this.name = 'YhikasSyncConfigError'\n }\n}\n\n/**\n * Row counts must be whole. `maxRowsPerResource` becomes a query `limit`,\n * which Postgres rejects when fractional — and it would fail at fetch time\n * with an error naming neither the config field nor the reason. Caught at the\n * boundary instead, where the message can say which knob is wrong.\n */\nfunction requirePositiveInteger(value: number, field: string): number {\n if (!Number.isSafeInteger(value) || value <= 0) {\n throw new YhikasSyncConfigError(`${field} must be a positive integer, got ${value}`)\n }\n return value\n}\n\nfunction requirePositive(value: number, field: string): number {\n if (!Number.isFinite(value) || value <= 0) {\n throw new YhikasSyncConfigError(`${field} must be a finite positive number, got ${value}`)\n }\n return value\n}\n\n/**\n * Resolve config + environment into the frozen object the plugin declares.\n *\n * Throws only on a STRUCTURALLY invalid config — a nonsensical number. It never\n * throws for a missing credential or a missing base URL: `lumi.config.ts` is\n * loaded at build time by the CLI, and throwing there breaks every `lumi`\n * command on a machine that simply has no secrets (see\n * `gotcha_lumi_config_throw_at_build_time`). A missing credential surfaces at\n * job-run time, where it becomes an alert.\n */\nexport function resolveYhikasSyncConfig(\n config: YhikasSyncConfig = {},\n env: NodeJS.ProcessEnv = process.env,\n): ResolvedYhikasSyncConfig {\n const maxRetireFraction = config.maxRetireFraction ?? DEFAULT_MAX_RETIRE_FRACTION\n if (!Number.isFinite(maxRetireFraction) || maxRetireFraction <= 0 || maxRetireFraction > 1) {\n throw new YhikasSyncConfigError(\n `maxRetireFraction must be in (0, 1], got ${maxRetireFraction}. A value of 1 disables the ` +\n `fraction rule; the empty-snapshot rule still applies.`,\n )\n }\n\n const rawBaseUrl = config.baseUrl ?? env[BASE_URL_ENV_VAR]\n\n return Object.freeze({\n baseUrl: rawBaseUrl && rawBaseUrl.length > 0 ? rawBaseUrl : undefined,\n schedule: config.schedule ?? DEFAULT_SYNC_SCHEDULE,\n watchdogSchedule: config.watchdogSchedule ?? DEFAULT_WATCHDOG_SCHEDULE,\n staleAfterMs: requirePositive(config.staleAfterMs ?? DEFAULT_STALE_AFTER_MS, 'staleAfterMs'),\n requestTimeoutMs: requirePositive(\n config.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS,\n 'requestTimeoutMs',\n ),\n maxRowsPerResource: requirePositiveInteger(\n config.maxRowsPerResource ?? DEFAULT_MAX_ROWS_PER_RESOURCE,\n 'maxRowsPerResource',\n ),\n maxRetireFraction,\n minRowsForRetireFloor: requirePositiveInteger(\n config.minRowsForRetireFloor ?? DEFAULT_MIN_ROWS_FOR_RETIRE_FLOOR,\n 'minRowsForRetireFloor',\n ),\n enabled: config.enabled ?? true,\n detailPages: resolveDetailPages(config.detailPages),\n roomPages: resolveRoomPages(config.roomPages),\n })\n}\n\nfunction resolveDetailPages(\n detailPages: YhikasSyncConfig['detailPages'],\n): ResolvedYhikasSyncConfig['detailPages'] {\n if (detailPages === undefined || detailPages.drawnBy === 'template') {\n return Object.freeze({ drawnBy: 'template' as const })\n }\n if (detailPages.drawnBy !== 'site') {\n throw new YhikasSyncConfigError(\n `detailPages.drawnBy must be 'template' or 'site', got ${String(\n (detailPages as { drawnBy?: unknown }).drawnBy,\n )}.`,\n )\n }\n const reason = typeof detailPages.reason === 'string' ? detailPages.reason.trim() : ''\n if (reason === '') {\n throw new YhikasSyncConfigError(\n \"detailPages: { drawnBy: 'site' } needs a `reason` — why this site draws room and \" +\n 'document pages by hand instead of with their detail templates.',\n )\n }\n return Object.freeze({ drawnBy: 'site' as const, reason })\n}\n\n/** A page slug — the grammar the page entity's slugs follow. */\nexport const PAGE_SLUG_PATTERN = /^[a-z0-9-]+$/\n\n/** An entity name — the grammar `defineEntity` names follow. */\nconst ENTITY_NAME_PATTERN = /^[a-z][a-z0-9_]{0,62}$/\n\n/**\n * An `https:` URL with no query, fragment or credentials, without its trailing\n * slash — or a config error. `https:` only: it is drawn as a link in the\n * admin, and a `javascript:` or `data:` URL must never reach an `href`.\n */\nexport function resolveAdminUrl(value: unknown, field: string): string {\n if (typeof value !== 'string' || value.length > 300) {\n throw new YhikasSyncConfigError(`${field} must be an https URL of at most 300 characters.`)\n }\n let url: URL\n try {\n url = new URL(value)\n } catch {\n // The parse error says nothing a reader of lumi.config.ts can act on;\n // the message below names the knob.\n throw new YhikasSyncConfigError(`${field} must be an absolute https URL, got '${value}'.`)\n }\n if (\n url.protocol !== 'https:' ||\n url.username !== '' ||\n url.password !== '' ||\n url.search !== '' ||\n url.hash !== ''\n ) {\n throw new YhikasSyncConfigError(\n `${field} must be a plain https URL — no credentials, query or fragment — got '${value}'.`,\n )\n }\n return `${url.origin}${url.pathname}`.replace(/\\/+$/, '')\n}\n\nfunction resolveRoomPages(\n roomPages: YhikasSyncConfig['roomPages'],\n): ResolvedYhikasSyncConfig['roomPages'] {\n if (roomPages === undefined) return undefined\n const entity = roomPages.entity ?? 'page'\n if (!ENTITY_NAME_PATTERN.test(entity)) {\n throw new YhikasSyncConfigError(`roomPages.entity must be an entity name, got '${entity}'.`)\n }\n if (typeof roomPages.parentSlug !== 'string' || !PAGE_SLUG_PATTERN.test(roomPages.parentSlug)) {\n throw new YhikasSyncConfigError(\n `roomPages.parentSlug must be a page slug (lowercase letters, digits, '-'), got ` +\n `'${String(roomPages.parentSlug)}'.`,\n )\n }\n if (typeof roomPages.draft !== 'function') {\n throw new YhikasSyncConfigError(\n 'roomPages.draft must be a function (room, locale) => { title, layout } — the new page.',\n )\n }\n if (roomPages.offered !== undefined && typeof roomPages.offered !== 'function') {\n throw new YhikasSyncConfigError('roomPages.offered must be a function (room) => boolean.')\n }\n return Object.freeze({\n entity,\n parentSlug: roomPages.parentSlug,\n offered: roomPages.offered ?? isRoomOffer,\n draft: roomPages.draft,\n adminUrl:\n roomPages.adminUrl === undefined\n ? undefined\n : resolveAdminUrl(roomPages.adminUrl, 'roomPages.adminUrl'),\n })\n}\n","/**\n * `yhikas_legal_document` — the local projection of yhikas-admin's\n * `legal_document` (active rows only; the upstream route filters\n * `is_active = true` server-side so an unpublished draft cannot leak).\n *\n * ## The body is HTML, and it is sanitized on the way in\n *\n * Upstream stores raw TipTap-authored HTML in parallel `html_content_et` /\n * `html_content_en` columns, so this side stores and renders trusted HTML\n * rather than markdown. Every value passes through `@murumets-ee/blocks`'\n * allowlist-based `sanitizeHtml()` at INGEST, not at render: sanitizing at the\n * boundary means the stored bytes are already safe, so a future reader that\n * forgets to sanitize is not a stored-XSS hole.\n *\n * \"It comes from our own admin\" is not a reason to skip it — F006 is precisely\n * why. The upstream price mutations are unauthenticated, which means the\n * premise that this content is trusted is weaker than it looks, and the sync\n * would propagate whatever it was handed with complete fidelity.\n *\n * ## Security\n *\n * Same two controls as `yhikas_room_type`: deny-by-default for every non-admin\n * role, plus `machineWritten` refusing any writer that is not the sync actor.\n * `access` is advisory metadata only.\n */\n\nimport { auditable, defineEntity, field, publishable } from '@murumets-ee/entity'\nimport { machineWritten } from './guard.js'\n\nexport const YHIKAS_LEGAL_DOCUMENT_ENTITY = 'yhikas_legal_document'\n\nexport const YhikasLegalDocument = defineEntity({\n name: YHIKAS_LEGAL_DOCUMENT_ENTITY,\n scope: 'global',\n // A legal document has its own page, drawn by the stored \"legal document\n // detail\" template (block-library D009).\n //\n // ⚠️ The path uses `sourceSlug`, which keeps the URLs yhikas-admin already\n // publishes — and `sourceSlug` is deliberately NOT unique (see the field).\n // A path, however, IS unique per locale: if two upstream documents ever\n // exchange slugs, the first write of that run claims a path the other still\n // holds and is refused, so that run fails until the slugs are untangled\n // upstream. `type` would never collide but would change every document URL.\n //\n // The same guard now sits in front of EVERY sync write of a routable row, so\n // a write is also refused when its path is already held by another document\n // (a page someone authored at `/dokumendid/<slug>`) or falls under a reserved\n // prefix. There is no slug to auto-suffix: the refusal is the outcome.\n frontend: {\n path: { et: '/dokumendid/[sourceSlug]', en: '/documents/[sourceSlug]' },\n drawnBy: 'template',\n },\n writtenBy: 'machine',\n fields: {\n /**\n * The business key. `legal_document.type` is `text().notNull().unique()`\n * upstream — NOT a pgEnum, and there is no TS union anywhere. The five\n * values seeded today are closed by convention only, so nothing here\n * validates membership in a list: doing so would turn a newly authored\n * upstream document into a hard sync failure.\n */\n type: field.text({ required: true, unique: true, indexed: true, maxLength: 190 }),\n\n title: field.text({ required: true, translatable: true }),\n\n /**\n * The upstream slug, verbatim — deliberately `field.text` and not\n * `field.slug`, which would regenerate it from a source field and silently\n * diverge from the anchor links yhikas-admin already publishes.\n *\n * Indexed but NOT unique: it is unique upstream, but the sync writes rows\n * one at a time, so a run in which two documents exchange slugs would hit\n * a transient collision mid-run and fail a sync that is not actually\n * invalid. `type` is the identity and carries the uniqueness guarantee.\n */\n sourceSlug: field.text({ required: true, indexed: true, maxLength: 190 }),\n\n /** Sanitized HTML. Translatable: `et` on the base row, `en` in the translations table. */\n body: field.richtext({ required: true, translatable: true }),\n\n /** Upstream display order. */\n order: field.number({ integer: true, required: true, default: 0 }),\n\n /**\n * Whether the upstream row carried non-empty English content. A\n * convenience for the renderer — the control is per-locale publish status\n * (D015). Serving Estonian legal text under an English URL is the failure\n * this exists to prevent, and it is worse than the document being absent:\n * rental terms and fire-safety instructions read as correct English-locale\n * content to every automated check.\n */\n hasEnglish: field.boolean({ required: true, default: false }),\n\n /**\n * Stable hash of the normalized upstream row. `legal_document` DOES carry\n * an `updated_at` — set by hand rather than by `$onUpdate` — but the public\n * route deliberately excludes it from the projection, so it is not\n * available as a change signal on this side.\n */\n sourceHash: field.text({ maxLength: 64 }),\n },\n behaviors: [auditable(), publishable(), machineWritten(YHIKAS_LEGAL_DOCUMENT_ENTITY)],\n access: {\n view: 'group.admin',\n create: 'group.admin',\n update: 'group.admin',\n delete: 'group.admin',\n },\n admin: {\n // Out of the admin menu (CLAUDE.md \"Screens first\"): nothing a person does\n // goes through this raw, machine-written table. The entity stays reachable\n // — entity API, content API, collection sources, live sources and a direct\n // URL all still read it; only the sidebar link is gone.\n hideFromMenu: true,\n group: 'yhikas',\n label: 'Legal documents',\n labelSingular: 'Legal document',\n titleField: 'type',\n icon: 'file-text',\n description:\n 'Synced from yhikas-admin every 6 hours. Read-only here — an edit would be reverted on the next run. Change the text in yhikas-admin.',\n defaultSort: 'order',\n defaultSortDirection: 'asc',\n disableCreate: true,\n hideFromDashboard: true,\n hiddenFields: ['sourceHash'],\n hiddenColumns: ['sourceHash'],\n },\n})\n","/**\n * `yhikas_site_info` — reception hours and the notice ticker, as ONE row.\n *\n * ## Why a singleton and not N notice rows\n *\n * Upstream `site_notice` carries `{text, isActive, order}` and nothing else —\n * no id, no code, no timestamp. So there is no business key an edit upstream\n * would preserve, and a diff keyed on `order` would read \"notice 2 was\n * reworded\" and \"notices 2 and 3 swapped places\" as the same event. Modelling\n * the resource as one row carrying an ordered list keeps identity honest: the\n * whole list is the value, and it is replaced as a value.\n *\n * That also matches what the site does with it. A ticker renders the list in\n * order; nothing links to an individual notice, so nothing needs one to have a\n * URL, a version or a publish state of its own.\n *\n * ## Both halves are optional, and that is the point\n *\n * `receptionHours` and `notices` can each legitimately be empty — a dormitory\n * with no current notices is an ordinary state. What is NOT ordinary is BOTH\n * being empty, because that is exactly what `/api/public/site-info` returns\n * when no row exists at all (F015), and it is indistinguishable at the wire\n * from a deliberate clearing. `projectSiteInfo` refuses that payload rather\n * than writing it (D027) — the refusal lives there because emptiness is a\n * content judgement, not a schema one.\n *\n * ## Security\n *\n * As with the other two synced entities: `access` is advisory metadata, the\n * firewall does not read it, and `admin` bypasses it unconditionally. The real\n * controls are deny-by-default (no non-admin role is granted anything here) and\n * `machineWritten`, which since PR 12 refuses non-sync writers on the base row\n * AND on the translation path.\n */\n\nimport { auditable, defineEntity, field, publishable } from '@murumets-ee/entity'\nimport { machineWritten } from './guard.js'\n\nexport const YHIKAS_SITE_INFO_ENTITY = 'yhikas_site_info'\n\nexport const YhikasSiteInfo = defineEntity({\n name: YHIKAS_SITE_INFO_ENTITY,\n /**\n * Declared, not merely documented. Everything above argues the singleton\n * semantics and the projection emits exactly one row — but the REGISTRY read\n * `entityKind: \"collection\"`, so `/api/content/v1/yhikas_site_info` answered\n * with the list envelope and any consumer reaching for a singleton read got\n * an array cast to an object, every field `undefined`. On a resource nobody\n * had populated yet that is indistinguishable from working (F056).\n *\n * The framework enforces the cardinality generically (D011): `create` refuses\n * a second live row, `delete` refuses to leave zero. Nothing here restates\n * the rule. Until now the \"exactly one\" guarantee was held only by the\n * projection and by `key`'s unique index — conventional rather than real.\n *\n * ⚠️ WIRE BREAK for any content-API consumer, at UPGRADE time rather than at\n * merge (customer repos pin `@murumets-ee/*` from npm). The envelope changes\n * from `{data:[row],meta}` to `{data:row}`, and `/count`, `/slug/:slug`,\n * `/:id` and every list parameter start refusing. A reader using\n * `listDocuments(…, {limit:1})` must move to `getSingleton()` in the same\n * upgrade step — see this PR's changeset.\n */\n kind: 'singleton',\n scope: 'global',\n fields: {\n /**\n * The business key, constant at {@link SITE_INFO_KEY}.\n *\n * A singleton still needs a key, for the same reason the other two do: the\n * differ matches local to upstream by key, and `applyPlan` upserts on it.\n * A constant key makes the singleton a one-element case of the same\n * machinery rather than a second code path.\n */\n key: field.text({ required: true, unique: true, indexed: true, maxLength: 32 }),\n\n /**\n * Reception hours as free text — `E–R 9–17`, or whatever the office writes.\n *\n * Deliberately NOT structured. Upstream stores one multilingual string and\n * the site renders it verbatim; parsing it into open/close pairs here would\n * invent a schema neither system has, and would fail the first time someone\n * writes \"suletud 24.06\".\n */\n receptionHours: field.text({ translatable: true, maxLength: 500 }),\n\n /**\n * The ticker, as an ordered array of strings for ONE locale.\n *\n * Already filtered to `isActive` and already sorted by `order` at\n * projection time, so a renderer maps over it and nothing more. `isActive`\n * and `order` are upstream's editing affordances, not content — carrying\n * them across would oblige every consumer to re-implement the same filter\n * and sort, and one of them would eventually get it wrong.\n */\n notices: field.json({ translatable: true }),\n\n /**\n * Whether the secondary locale has anything publishable. A convenience\n * flag for the admin list, never the control — per-locale publish status is.\n */\n hasEnglish: field.boolean({ default: false, required: true }),\n\n /** The change signal. Written LAST, so a stale hash means \"redo this row\". */\n sourceHash: field.text({ maxLength: 64 }),\n },\n behaviors: [auditable(), publishable(), machineWritten(YHIKAS_SITE_INFO_ENTITY)],\n access: {\n view: 'group.admin',\n create: 'group.admin',\n update: 'group.admin',\n delete: 'group.admin',\n },\n admin: {\n // Out of the admin menu (CLAUDE.md \"Screens first\"): nothing a person does\n // goes through this raw, machine-written table. The entity stays reachable\n // — entity API, content API, collection sources, live sources and a direct\n // URL all still read it; only the sidebar link is gone.\n hideFromMenu: true,\n group: 'yhikas',\n label: 'Site info',\n titleField: 'key',\n disableCreate: true,\n hideFromDashboard: true,\n hiddenFields: ['sourceHash'],\n hiddenColumns: ['sourceHash'],\n },\n})\n","/**\n * The entities this plugin syncs — ONE list, consumed by the plugin's\n * declaration and by the contract tests.\n *\n * Adding `site_info` turned twelve tests red across two files, every one of\n * them because a resource or entity had been re-listed by hand somewhere. This\n * export exists so the next one cannot: `plugin.ts` spreads it into\n * `server.entities`, and `contract.test.ts` iterates it, so a fourth entity is\n * covered by both the moment it is added here.\n */\n\nimport type { ResolvedYhikasSyncConfig } from '../config.js'\nimport { YhikasLegalDocument } from './legal-document.js'\nimport { YhikasRoomType } from './room-type.js'\nimport { YhikasSiteInfo } from './site-info.js'\n\nexport const SYNCED_ENTITIES = [YhikasRoomType, YhikasLegalDocument, YhikasSiteInfo] as const\n\n/** What decides the entity variants: who draws the detail pages, and whether rooms are pages. */\nexport type EntityVariantConfig = Pick<ResolvedYhikasSyncConfig, 'detailPages' | 'roomPages'>\n\n/**\n * The two entities with a detail page, as the site chose to draw them\n * (`yhikasSync({ detailPages, roomPages })`).\n *\n * - `detailPages: 'template'` (default) — the definitions themselves.\n * - `detailPages: 'site'` — copies whose `frontend` says `drawnBy: 'site'`\n * with the site's reason (block-library PR19); same name, fields and paths,\n * so rows, path rows and the sync are unchanged.\n * - `roomPages` set (block-library PR21, D010) — the room type is a copy with\n * NO `frontend` at all: a room is not routable from its synced row, because\n * an ordinary page under the room list page owns `/<parent>/<code>`. Legal\n * documents still follow `detailPages`; with `roomPages`, `detailPages` no\n * longer affects room types.\n */\nexport function detailPageEntities(config: EntityVariantConfig): {\n readonly roomType: typeof YhikasRoomType\n readonly legalDocument: typeof YhikasLegalDocument\n} {\n const { detailPages, roomPages } = config\n const handBuilt = <E extends typeof YhikasRoomType | typeof YhikasLegalDocument>(\n entity: E,\n reason: string,\n ): E => {\n const path = entity.frontend?.path\n if (path === undefined) {\n throw new Error(`yhikas-sync: ${entity.name} declares no frontend path to draw by hand`)\n }\n return { ...entity, frontend: { path, drawnBy: 'site', reason } }\n }\n const legalDocument =\n detailPages.drawnBy === 'template'\n ? YhikasLegalDocument\n : handBuilt(YhikasLegalDocument, detailPages.reason)\n if (roomPages !== undefined) {\n return { roomType: unroutable(YhikasRoomType), legalDocument }\n }\n const roomType =\n detailPages.drawnBy === 'template'\n ? YhikasRoomType\n : handBuilt(YhikasRoomType, detailPages.reason)\n return { roomType, legalDocument }\n}\n\n/** A copy of `entity` that declares no frontend path — same name and fields, no URL. */\nfunction unroutable<E extends typeof YhikasRoomType>(entity: E): E {\n const { frontend: _frontend, ...rest } = entity\n return rest as E\n}\n\n/** Every synced entity, as the site chose to draw (or not route) them. */\nexport function syncedEntities(\n config: EntityVariantConfig,\n): readonly (typeof SYNCED_ENTITIES)[number][] {\n if (config.detailPages.drawnBy === 'template' && config.roomPages === undefined) {\n return SYNCED_ENTITIES\n }\n const { roomType, legalDocument } = detailPageEntities(config)\n return [roomType, legalDocument, YhikasSiteInfo]\n}\n","/**\n * `yhikas_sync_state` — one row per synced resource, recording when that\n * resource last synced successfully.\n *\n * ## Why this table exists at all\n *\n * Phase 05 hoped the job could record success through\n * `@murumets-ee/queue`'s `heartbeats-table.ts` \"rather than inventing a status\n * field\". Measured (R012 §1), that is not available:\n *\n * - `toolkit_worker_heartbeats` is keyed on `workerId` — one row per worker\n * PROCESS. It answers \"is any worker alive\", never \"did this schedule fire\".\n * - `QueueAlerter.recordFailure` is reached from exactly one place,\n * `failJob`'s dead-letter branch, so it fires only on a job that RAN and\n * THREW. A job that never starts produces no error and therefore no alert.\n *\n * The failure S7 requires be observable — a job that silently stops running\n * while the site keeps serving last month's prices — is precisely the one\n * neither can see. So the detection is new; only the alert channel is\n * inherited (the watchdog throws, and the throw reaches the shipped\n * dedupe/digest/email path).\n *\n * This is infrastructure, not content: `defineTable` + `TableClient`, per\n * CLAUDE.md's two-tier rule. It is also the surface a human or a monitor reads\n * to answer \"how stale is the site's price sheet right now\", which is what\n * makes the staleness observable rather than merely bounded.\n */\n\nimport { column, defineTable } from '@murumets-ee/db'\n\nexport const yhikasSyncStateTable = defineTable({\n name: 'yhikas_sync_state',\n columns: {\n /** `room_types` | `legal_documents` — see `SyncResource`. */\n resource: column.varchar({ length: 64, notNull: true }),\n\n /**\n * When this resource was first tracked. The watchdog measures staleness\n * from `lastSuccessAt ?? firstSeenAt`, so a sync that has NEVER once\n * succeeded eventually alerts instead of looking indistinguishable from\n * one that is merely young.\n */\n firstSeenAt: column.timestamp({\n notNull: true,\n withTimezone: true,\n defaultNow: true,\n pgName: 'first_seen_at',\n }),\n\n /** Updated on every run, successful or not. */\n lastAttemptAt: column.timestamp({ withTimezone: true, pgName: 'last_attempt_at' }),\n\n /** Updated ONLY on a fully applied run. This is what the watchdog reads. */\n lastSuccessAt: column.timestamp({ withTimezone: true, pgName: 'last_success_at' }),\n\n /**\n * The last failure's message, cleared on the next success. Bounded, and\n * carries no credential: the client never puts a response body or a key\n * into an error message.\n */\n lastError: column.varchar({ length: 1024, pgName: 'last_error' }),\n\n /** Counts from the last successful run — what a human wants to see first. */\n lastCreated: column.integer({ notNull: true, default: 0, pgName: 'last_created' }),\n lastUpdated: column.integer({ notNull: true, default: 0, pgName: 'last_updated' }),\n lastRetired: column.integer({ notNull: true, default: 0, pgName: 'last_retired' }),\n lastUnchanged: column.integer({ notNull: true, default: 0, pgName: 'last_unchanged' }),\n },\n primaryKey: 'resource',\n})\n"],"mappings":"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"}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
|
|
2
|
+
import { ReactElement } from "react";
|
|
3
|
+
|
|
4
|
+
//#region src/widgets-client/unpublish-room-page.d.ts
|
|
5
|
+
interface UnpublishRoomPageButtonProps {
|
|
6
|
+
/** `<apiBasePath>/<entity>/<id>` — built on the server from the admin's own config. */
|
|
7
|
+
readonly url: string;
|
|
8
|
+
/** The default locale's code — names the base row in a failure. */
|
|
9
|
+
readonly defaultLocale: string;
|
|
10
|
+
/** The non-default content locales, unpublished after the base row. */
|
|
11
|
+
readonly locales: readonly string[];
|
|
12
|
+
readonly label: string;
|
|
13
|
+
readonly busyLabel: string;
|
|
14
|
+
/** The failure sentence, with `{failures}` where `en 403, ru 0` goes. */
|
|
15
|
+
readonly failedTemplate: string;
|
|
16
|
+
}
|
|
17
|
+
declare function UnpublishRoomPageButton({
|
|
18
|
+
url,
|
|
19
|
+
defaultLocale,
|
|
20
|
+
locales,
|
|
21
|
+
label,
|
|
22
|
+
busyLabel,
|
|
23
|
+
failedTemplate
|
|
24
|
+
}: UnpublishRoomPageButtonProps): ReactElement;
|
|
25
|
+
//#endregion
|
|
26
|
+
export { UnpublishRoomPageButton, type UnpublishRoomPageButtonProps };
|
|
27
|
+
//# sourceMappingURL=widgets-client.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"widgets-client.d.mts","names":[],"sources":["../src/widgets-client/unpublish-room-page.tsx"],"mappings":";;;;UAyBiB,4BAAA;EAoEf;EAAA,SAlES,GAAA;EAmEuB;EAAA,SAjEvB,aAAA;EAiEmC;EAAA,SA/DnC,OAAA;EAAA,SACA,KAAA;EAAA,SACA,SAAA;EAwDT;EAAA,SAtDS,cAAA;AAAA;AAAA,iBAoDK,uBAAA,CAAA;EACd,GAAA;EACA,aAAA;EACA,OAAA;EACA,KAAA;EACA,SAAA;EACA;AAAA,GACC,4BAAA,GAA+B,YAAA"}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import{useRouter as e}from"next/navigation";import{useState as t}from"react";import{jsx as n,jsxs as r}from"react/jsx-runtime";function i(e,t){return fetch(e,{method:`PATCH`,credentials:`same-origin`,headers:{"Content-Type":`application/json`},body:JSON.stringify(t)})}async function a(e,t){try{let n=await i(e,t);return n.ok?void 0:n.status}catch{return 0}}async function o(e,t,n){let r=await e({status:`draft`});if(r!==void 0)return[`${t} ${r}`];let i=[];for(let t of n){let n=await e({status:`draft`,_locale:t});n!==void 0&&i.push(`${t} ${n}`)}return i}function s({url:i,defaultLocale:s,locales:c,label:l,busyLabel:u,failedTemplate:d}){let f=e(),[p,m]=t({kind:`idle`});async function h(){m({kind:`busy`});let e=await o(e=>a(i,e),s,c);if(e.length>0){m({kind:`failed`,failures:e.join(`, `)});return}m({kind:`idle`}),f.refresh()}return r(`span`,{className:`inline-flex flex-col items-end gap-1`,children:[n(`button`,{type:`button`,onClick:()=>void h(),disabled:p.kind===`busy`,className:`rounded-md border border-rose-300 px-2 py-1 text-xs font-medium text-rose-700 hover:bg-rose-50 disabled:opacity-60 dark:border-rose-800 dark:text-rose-300 dark:hover:bg-rose-950`,children:p.kind===`busy`?u:l}),p.kind===`failed`?n(`span`,{role:`alert`,className:`text-xs text-rose-600 dark:text-rose-400`,children:d.replace(`{failures}`,p.failures)}):null]})}export{s as UnpublishRoomPageButton};
|
|
3
|
+
//# sourceMappingURL=widgets-client.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"widgets-client.mjs","names":[],"sources":["../src/widgets-client/unpublish-room-page.tsx"],"sourcesContent":["/**\n * \"Unpublish\" — takes a retired room's page off the site through the EXISTING\n * authenticated admin API: `PATCH <apiBasePath>/<entity>/<id>` with\n * `{ status: 'draft' }`, the same request the entity list's status toggle\n * sends. Nothing new on the server:\n *\n * - CSRF — a same-origin `fetch` carries the `Origin` header the api-handler\n * validates on every mutation;\n * - authority — the handler's unpublish gate requires `publish` on the entity\n * for taking a live row offline. The server widget draws this button only\n * for a viewer who holds it; the gate still decides.\n *\n * The base row first, then each other locale (`_locale`), because a locale\n * published on its own stays published when only the base row is unpublished\n * (the public read predicate is `COALESCE(locale_status.status, main.status)`).\n *\n * `router.refresh()` ONLY when every request succeeded. On any failure the\n * error stays on screen and names what failed (`en 403`), and a second click\n * re-sends them all — each request is idempotent. No `useEffect`, no fetch on\n * mount — the requests run only on the click.\n */\n\nimport { useRouter } from 'next/navigation'\nimport { type ReactElement, useState } from 'react'\n\nexport interface UnpublishRoomPageButtonProps {\n /** `<apiBasePath>/<entity>/<id>` — built on the server from the admin's own config. */\n readonly url: string\n /** The default locale's code — names the base row in a failure. */\n readonly defaultLocale: string\n /** The non-default content locales, unpublished after the base row. */\n readonly locales: readonly string[]\n readonly label: string\n readonly busyLabel: string\n /** The failure sentence, with `{failures}` where `en 403, ru 0` goes. */\n readonly failedTemplate: string\n}\n\ntype State =\n | { readonly kind: 'idle' }\n | { readonly kind: 'busy' }\n | { readonly kind: 'failed'; readonly failures: string }\n\nfunction patch(url: string, body: Readonly<Record<string, string>>): Promise<Response> {\n return fetch(url, {\n method: 'PATCH',\n credentials: 'same-origin',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify(body),\n })\n}\n\n/** One request's outcome: `undefined` when it succeeded, else its status (0 — no answer). */\nasync function attempt(\n url: string,\n body: Readonly<Record<string, string>>,\n): Promise<number | undefined> {\n try {\n const res = await patch(url, body)\n return res.ok ? undefined : res.status\n } catch {\n // A network failure has no HTTP status; 0 says \"no answer\".\n return 0\n }\n}\n\n/**\n * Every request, in order — the base row, then each other locale — and what\n * failed (`en 403`). Stops at a refused base row: nothing changed, so the\n * locales are not tried. Pure, so the rule \"refresh only when this is empty\"\n * is tested without rendering.\n */\nexport async function unpublishAll(\n send: (body: Readonly<Record<string, string>>) => Promise<number | undefined>,\n defaultLocale: string,\n locales: readonly string[],\n): Promise<readonly string[]> {\n const base = await send({ status: 'draft' })\n if (base !== undefined) return [`${defaultLocale} ${base}`]\n const failures: string[] = []\n for (const locale of locales) {\n const status = await send({ status: 'draft', _locale: locale })\n if (status !== undefined) failures.push(`${locale} ${status}`)\n }\n return failures\n}\n\nexport function UnpublishRoomPageButton({\n url,\n defaultLocale,\n locales,\n label,\n busyLabel,\n failedTemplate,\n}: UnpublishRoomPageButtonProps): ReactElement {\n const router = useRouter()\n const [state, setState] = useState<State>({ kind: 'idle' })\n\n async function unpublish(): Promise<void> {\n setState({ kind: 'busy' })\n const failures = await unpublishAll((body) => attempt(url, body), defaultLocale, locales)\n if (failures.length > 0) {\n setState({ kind: 'failed', failures: failures.join(', ') })\n return\n }\n setState({ kind: 'idle' })\n router.refresh()\n }\n\n return (\n <span className=\"inline-flex flex-col items-end gap-1\">\n <button\n type=\"button\"\n onClick={() => void unpublish()}\n disabled={state.kind === 'busy'}\n className=\"rounded-md border border-rose-300 px-2 py-1 text-xs font-medium text-rose-700 hover:bg-rose-50 disabled:opacity-60 dark:border-rose-800 dark:text-rose-300 dark:hover:bg-rose-950\"\n >\n {state.kind === 'busy' ? busyLabel : label}\n </button>\n {state.kind === 'failed' ? (\n <span role=\"alert\" className=\"text-xs text-rose-600 dark:text-rose-400\">\n {failedTemplate.replace('{failures}', state.failures)}\n </span>\n ) : null}\n </span>\n )\n}\n"],"mappings":";+HA2CA,SAAS,EAAM,EAAa,EAA2D,CACrF,OAAO,MAAM,EAAK,CAChB,OAAQ,QACR,YAAa,cACb,QAAS,CAAE,eAAgB,kBAAmB,EAC9C,KAAM,KAAK,UAAU,CAAI,CAC3B,CAAC,CACH,CAGA,eAAe,EACb,EACA,EAC6B,CAC7B,GAAI,CACF,IAAM,EAAM,MAAM,EAAM,EAAK,CAAI,EACjC,OAAO,EAAI,GAAK,IAAA,GAAY,EAAI,MAClC,MAAQ,CAEN,MAAO,EACT,CACF,CAQA,eAAsB,EACpB,EACA,EACA,EAC4B,CAC5B,IAAM,EAAO,MAAM,EAAK,CAAE,OAAQ,OAAQ,CAAC,EAC3C,GAAI,IAAS,IAAA,GAAW,MAAO,CAAC,GAAG,EAAc,GAAG,GAAM,EAC1D,IAAM,EAAqB,CAAC,EAC5B,IAAK,IAAM,KAAU,EAAS,CAC5B,IAAM,EAAS,MAAM,EAAK,CAAE,OAAQ,QAAS,QAAS,CAAO,CAAC,EAC1D,IAAW,IAAA,IAAW,EAAS,KAAK,GAAG,EAAO,GAAG,GAAQ,CAC/D,CACA,OAAO,CACT,CAEA,SAAgB,EAAwB,CACtC,MACA,gBACA,UACA,QACA,YACA,kBAC6C,CAC7C,IAAM,EAAS,EAAU,EACnB,CAAC,EAAO,GAAY,EAAgB,CAAE,KAAM,MAAO,CAAC,EAE1D,eAAe,GAA2B,CACxC,EAAS,CAAE,KAAM,MAAO,CAAC,EACzB,IAAM,EAAW,MAAM,EAAc,GAAS,EAAQ,EAAK,CAAI,EAAG,EAAe,CAAO,EACxF,GAAI,EAAS,OAAS,EAAG,CACvB,EAAS,CAAE,KAAM,SAAU,SAAU,EAAS,KAAK,IAAI,CAAE,CAAC,EAC1D,MACF,CACA,EAAS,CAAE,KAAM,MAAO,CAAC,EACzB,EAAO,QAAQ,CACjB,CAEA,OACE,EAAC,OAAD,CAAM,UAAU,gDAAhB,CACE,EAAC,SAAD,CACE,KAAK,SACL,YAAe,KAAK,EAAU,EAC9B,SAAU,EAAM,OAAS,OACzB,UAAU,6LAET,EAAM,OAAS,OAAS,EAAY,CAC/B,CAAA,EACP,EAAM,OAAS,SACd,EAAC,OAAD,CAAM,KAAK,QAAQ,UAAU,oDAC1B,EAAe,QAAQ,aAAc,EAAM,QAAQ,CAChD,CAAA,EACJ,IACA,GAEV"}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { d as RoomPageRefusalKind, n as RoomPageRecord, p as AdminClientLike } from "./room-page-registry-CnqXH_Ls.mjs";
|
|
2
|
+
import { Entity } from "@murumets-ee/entity";
|
|
3
|
+
import { RequireEntityPermissionConfig } from "@murumets-ee/admin-ui/pages";
|
|
4
|
+
import { ReactElement } from "react";
|
|
5
|
+
|
|
6
|
+
//#region src/widgets/room-pages-data.d.ts
|
|
7
|
+
/** The most room pages the widget reads and lists. */
|
|
8
|
+
declare const MAX_LISTED_ROOM_PAGES = 50;
|
|
9
|
+
interface RoomPageRow {
|
|
10
|
+
readonly id: string;
|
|
11
|
+
readonly title: string;
|
|
12
|
+
readonly status: string | null;
|
|
13
|
+
/** Set the first time the page was published; `null` when it never was. */
|
|
14
|
+
readonly everPublished: boolean;
|
|
15
|
+
/** Some non-default locale is published on its own (`<entity>_locale_status`). */
|
|
16
|
+
readonly localePublished: boolean;
|
|
17
|
+
}
|
|
18
|
+
interface RoomPagesWidgetReaders {
|
|
19
|
+
/** The newest recorded room pages, at most `limit`. */
|
|
20
|
+
readonly records: (limit: number) => Promise<readonly RoomPageRecord[]>;
|
|
21
|
+
/** These pages, as the viewer may read them. Throws when the viewer may not read pages at all. */
|
|
22
|
+
readonly pages: (ids: readonly string[]) => Promise<readonly RoomPageRow[]>;
|
|
23
|
+
/**
|
|
24
|
+
* Which of these codes are PUBLISHED room types. Throws when the viewer may
|
|
25
|
+
* not read room types — the retired list is then left out.
|
|
26
|
+
*/
|
|
27
|
+
readonly publishedCodes: (codes: readonly string[]) => Promise<ReadonlySet<string>>;
|
|
28
|
+
}
|
|
29
|
+
interface ListedRoomPage {
|
|
30
|
+
readonly code: string;
|
|
31
|
+
readonly pageId: string;
|
|
32
|
+
readonly title: string;
|
|
33
|
+
}
|
|
34
|
+
interface RefusedRoomPage {
|
|
35
|
+
readonly code: string;
|
|
36
|
+
readonly kind: RoomPageRefusalKind;
|
|
37
|
+
readonly message: string;
|
|
38
|
+
}
|
|
39
|
+
interface RoomPagesWidgetData {
|
|
40
|
+
readonly waiting: readonly ListedRoomPage[];
|
|
41
|
+
readonly retired: readonly ListedRoomPage[];
|
|
42
|
+
readonly refused: readonly RefusedRoomPage[];
|
|
43
|
+
/** `false` when the viewer may not read room types, so retired rooms could not be checked. */
|
|
44
|
+
readonly retiredChecked: boolean;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* The widget's data, or `null` when the viewer may not read pages — the
|
|
48
|
+
* widget then renders nothing.
|
|
49
|
+
*/
|
|
50
|
+
declare function loadRoomPagesWidgetData(readers: RoomPagesWidgetReaders): Promise<RoomPagesWidgetData | null>;
|
|
51
|
+
//#endregion
|
|
52
|
+
//#region src/widgets/room-pages-widget.d.ts
|
|
53
|
+
interface RoomPagesWidgetProps {
|
|
54
|
+
/**
|
|
55
|
+
* The dashboard passes its whole `AdminPagesConfig`; this widget reads
|
|
56
|
+
* these. `createClient`, `auth` and `loadRoles` are optional only because
|
|
57
|
+
* the declared widget prop shape does not promise them — without
|
|
58
|
+
* `createClient` nothing is drawn, without `auth`/`loadRoles` no button is.
|
|
59
|
+
*/
|
|
60
|
+
readonly config: {
|
|
61
|
+
readonly adminBasePath: string;
|
|
62
|
+
readonly apiBasePath?: string | undefined;
|
|
63
|
+
readonly createClient?: ((entity: Entity) => Promise<AdminClientLike>) | undefined;
|
|
64
|
+
readonly auth?: RequireEntityPermissionConfig['auth'] | undefined;
|
|
65
|
+
readonly loadRoles?: RequireEntityPermissionConfig['loadRoles'] | undefined;
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
declare function RoomPagesWidget({
|
|
69
|
+
config
|
|
70
|
+
}: RoomPagesWidgetProps): Promise<ReactElement | null>;
|
|
71
|
+
//#endregion
|
|
72
|
+
export { type ListedRoomPage, MAX_LISTED_ROOM_PAGES, type RoomPageRow, RoomPagesWidget, type RoomPagesWidgetData, type RoomPagesWidgetProps, type RoomPagesWidgetReaders, loadRoomPagesWidgetData };
|
|
73
|
+
//# sourceMappingURL=widgets.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"widgets.d.mts","names":[],"sources":["../src/widgets/room-pages-data.ts","../src/widgets/room-pages-widget.tsx"],"mappings":";;;;;;AAsCA;AAAA,cAZa,qBAAA;AAAA,UAEI,WAAA;EAAA,SACN,EAAA;EAAA,SACA,KAAA;EAAA,SACA,MAAA;EAWmC;EAAA,SATnC,aAAA;EAc8C;EAAA,SAZ9C,eAAA;AAAA;AAAA,UAGM,sBAAA;EAEI;EAAA,SAAV,OAAA,GAAU,KAAA,aAAkB,OAAA,UAAiB,cAAA;EAAA;EAAA,SAE7C,KAAA,GAAQ,GAAA,wBAA2B,OAAA,UAAiB,WAAA;EAA5C;;;;EAAA,SAKR,cAAA,GAAiB,KAAA,wBAA6B,OAAA,CAAQ,WAAA;AAAA;AAAA,UAGhD,cAAA;EAAA,SACN,IAAA;EAAA,SACA,MAAA;EAAA,SACA,KAAA;AAAA;AAAA,UAGM,eAAA;EAAA,SACN,IAAA;EAAA,SACA,IAAA,EAAM,mBAAmB;EAAA,SACzB,OAAA;AAAA;AAAA,UAGM,mBAAA;EAAA,SACN,OAAA,WAAkB,cAAA;EAAA,SAClB,OAAA,WAAkB,cAAA;EAAA,SAClB,OAAA,WAAkB,eAAA;;WAElB,cAAA;AAAA;;;;;iBAgDW,uBAAA,CACpB,OAAA,EAAS,sBAAA,GACR,OAAA,CAAQ,mBAAA;;;UCnFM,oBAAA;EDMsB;;;;;;EAAA,SCC5B,MAAA;IAAA,SACE,aAAA;IAAA,SACA,WAAA;IAAA,SACA,YAAA,KAAiB,MAAA,EAAQ,MAAA,KAAW,OAAA,CAAQ,eAAA;IAAA,SAC5C,IAAA,GAAO,6BAAA;IAAA,SACP,SAAA,GAAY,6BAAA;EAAA;AAAA;AAAA,iBAwIH,eAAA,CAAA;EACpB;AAAA,GACC,oBAAA,GAAuB,OAAA,CAAQ,YAAA"}
|
package/dist/widgets.mjs
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import{i as e}from"./room-page-registry-BFbyGTC7.mjs";import{DEFAULT_LIMIT as t}from"@murumets-ee/db";import{UnpublishRoomPageButton as n}from"@murumets-ee/yhikas-sync/widgets-client";import{Fragment as r,jsx as i,jsxs as a}from"react/jsx-runtime";const o=50;async function s(n,r,i){let a=Math.max(1,i-1),o=Math.max(1,Math.floor(t/a)),s=new Map;for(let i of e([...new Set(r)],o)){let e=await n(i),r=0;for(let t of e.values())r+=Object.keys(t).length;let a=r<t||i.length===1,o=a?[e]:[];if(!a)for(let e of i)o.push(await n([e]));for(let e of o)for(let[t,n]of e)s.set(t,n)}return s}async function c(e){let t=await e.records(50),n=t.flatMap(e=>e.pageId===null?[]:[{...e,pageId:e.pageId}]),r=t.flatMap(e=>e.pageId===null&&e.lastError!==null?[{code:e.code,kind:e.lastErrorKind??`refused`,message:e.lastError}]:[]),i=[];try{i=await e.pages(n.map(e=>e.pageId))}catch{return null}let a=new Map(i.map(e=>[e.id,e])),o=[];for(let e of n){let t=a.get(e.pageId);e.origin!==`created`||t===void 0||t.status!==`published`&&!t.everPublished&&!t.localePublished&&o.push({code:e.code,pageId:e.pageId,title:t.title})}let s=n.flatMap(e=>{let t=a.get(e.pageId);return t!==void 0&&(t.status===`published`||t.localePublished)?[{code:e.code,pageId:e.pageId,title:t.title}]:[]});if(s.length===0)return{waiting:o,retired:[],refused:r,retiredChecked:!0};let c;try{c=await e.publishedCodes(s.map(e=>e.code))}catch{return{waiting:o,retired:[],refused:r,retiredChecked:!1}}return{waiting:o,retired:s.filter(e=>!c.has(e.code)),refused:r,retiredChecked:!0}}const l=e=>typeof e==`string`?e:null;function u(e,t,n,r){return e.has(t)?e(t,r):n}async function d(e){let t=e.createClient;if(t===void 0)return null;let{getApp:n,getPluginConfig:r,hasPluginConfig:i}=await import(`@murumets-ee/core`),{YHIKAS_SYNC_PLUGIN_NAME:a}=await import(`./constants-Bmm3P17G.mjs`).then(e=>e.T);if(!i(a))return null;let o=r(a).roomPages;if(o===void 0)return null;let u=n(),d=u.entities.get(o.entity);if(d===void 0||!await f(e,d,`view`))return null;let{YHIKAS_ROOM_TYPE_ENTITY:p,YhikasRoomType:m}=await import(`./room-type-DtMaQbza.mjs`).then(e=>e.i),h=u.entities.get(p)??m,{createRoomPageRegistry:g}=await import(`./room-page-registry-BFbyGTC7.mjs`).then(e=>e.o),{yhikasRoomPagesTable:_}=await import(`./room-pages-table-Vd9BqTSl.mjs`).then(e=>e.i),{columnOf:v,toRoomTypeReader:y}=await import(`./room-pages-adapters-DxFWkFFF.mjs`).then(e=>e.n),{inArray:b}=await import(`drizzle-orm`),{ContentClient:x}=await import(`@murumets-ee/content/client`),{enabledLocaleCodes:S,resolveEffectiveLocales:C,resolveI18nConfig:w}=await import(`@murumets-ee/content`),{defaultLocale:T}=await w(u),E=await C(u),D=await c({records:e=>g(_.makeClient(u.db.readOnly)).list(e),pages:async e=>{let n=await t(d);if(e.length===0)return[];let r=await n.findMany({where:b(v(n.getTable(),o.entity,`id`),[...e]),limit:e.length,locale:T,defaultLocale:T}),i=r.flatMap(e=>{let t=l(e.id);return t===null?[]:[t]}),a=new x({admin:n,entity:d}),c=await s(e=>a.getLocaleStatusBatch(e),i,E.length),u=new Set(S(E));return r.flatMap(e=>{let t=l(e.id);if(t===null)return[];let n=Object.entries(c.get(t)??{}).filter(([e])=>u.has(e)).map(([,e])=>e);return[{id:t,title:l(e.title)??``,status:l(e.status),everPublished:e.publishedAt!==null&&e.publishedAt!==void 0,localePublished:n.some(e=>e.status===`published`)}]})},publishedCodes:async e=>{let n=await y(await t(h),T).published(T,{limit:e.length,codes:e});return new Set(n.map(e=>e.code))}});return D===null?null:{data:D,pageEntity:d,defaultLocale:T,locales:D.retired.length>0?S(E):[]}}async function f(e,t,n){let{auth:r,loadRoles:i}=e;if(r===void 0||i===void 0)return!1;try{let{hasEntityPermission:e}=await import(`@murumets-ee/admin-ui/pages`);return await e(t,n,{auth:r,loadRoles:i})}catch(e){return console.error(`[yhikas-sync] room pages widget could not check ${n} permission`,e),!1}}async function p({config:e}){let t;try{t=await d(e)}catch(e){return console.error(`[yhikas-sync] room pages widget could not load`,e),null}if(t===null)return null;let{data:o,pageEntity:s,defaultLocale:c,locales:l}=t;if(o.waiting.length===0&&o.retired.length===0&&o.refused.length===0)return null;let{getLocale:p,getTranslations:m}=await import(`next-intl/server`),{entityNameToSlug:h}=await import(`@murumets-ee/admin-ui/server`),g=await p(),_=await m(),v=e.adminBasePath.replace(`:locale`,g),y=(e.apiBasePath??`/api/admin`).replace(/\/+$/,``),b=h(s.name),x=l.filter(e=>e!==c),S=o.retired.length>0&&await f(e,s,`publish`),C=o.waiting.length;return a(`section`,{className:`rounded-xl border bg-card p-4 text-card-foreground shadow-sm`,children:[i(`h2`,{className:`text-sm font-medium`,children:u(_,`YhikasSync.roomPages.title`,`Room pages`)}),C>0?a(`div`,{className:`mt-3`,children:[i(`p`,{className:`text-sm`,children:u(_,`YhikasSync.roomPages.waiting`,C===1?`1 new room waits for text and photos`:`${C} new rooms wait for text and photos`,{count:C})}),i(`ul`,{className:`mt-2 space-y-1`,children:o.waiting.map(e=>a(`li`,{className:`flex items-center justify-between gap-2 text-sm`,children:[a(`span`,{className:`truncate`,children:[i(`span`,{className:`font-mono text-xs text-muted-foreground`,children:e.code}),` `,e.title]}),i(`a`,{href:`${v}/${b}/${encodeURIComponent(e.pageId)}`,className:`shrink-0 text-xs underline hover:text-foreground`,children:u(_,`YhikasSync.roomPages.open`,`Open`)})]},e.pageId))})]}):null,o.retired.length>0?i(`ul`,{className:`mt-3 space-y-2`,children:o.retired.map(e=>i(`li`,{className:`flex items-start justify-between gap-2 text-sm`,children:S?a(r,{children:[i(`span`,{className:`text-rose-700 dark:text-rose-300`,children:u(_,`YhikasSync.roomPages.retired`,`Room ${e.code} no longer exists in yhikas-admin — unpublish?`,{code:e.code})}),i(n,{url:`${y}/${encodeURIComponent(s.name)}/${encodeURIComponent(e.pageId)}`,defaultLocale:c,locales:x,label:u(_,`YhikasSync.roomPages.unpublish`,`Unpublish`),busyLabel:u(_,`YhikasSync.roomPages.unpublishing`,`Unpublishing…`),failedTemplate:u(_,`YhikasSync.roomPages.unpublishFailed`,`Not unpublished: {failures}. Nothing is refreshed until every language is down — try again.`,{failures:`{failures}`})})]}):i(`span`,{className:`text-rose-700 dark:text-rose-300`,children:u(_,`YhikasSync.roomPages.retiredAsk`,`Room ${e.code} no longer exists in yhikas-admin, but its page is still published. Someone who may publish pages should unpublish it.`,{code:e.code})})},e.pageId))}):null,o.refused.length>0?i(`ul`,{className:`mt-3 space-y-2`,children:o.refused.map(e=>i(`li`,{className:`text-sm text-amber-700 dark:text-amber-300`,children:e.kind===`room-type-path`?u(_,`YhikasSync.roomPages.refusedRoomTypePath`,`Room ${e.code} could not get a page: its address is still held by the room type's old URL (${e.message}). Remove the yhikas_room_type rows from the path index (toolkit_paths); the next sync then creates the page.`,{code:e.code,reason:e.message}):u(_,`YhikasSync.roomPages.refused`,`Room ${e.code} could not get a page: ${e.message}`,{code:e.code,reason:e.message})},`refused-${e.code}`))}):null]})}export{o as MAX_LISTED_ROOM_PAGES,p as RoomPagesWidget,c as loadRoomPagesWidgetData};
|
|
2
|
+
//# sourceMappingURL=widgets.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"widgets.mjs","names":[],"sources":["../src/widgets/room-pages-data.ts","../src/widgets/room-pages-widget.tsx"],"sourcesContent":["/**\n * What the room pages dashboard widget shows — the pure half, behind\n * structural readers, so it is tested without a database or a React render.\n *\n * Two lists, both from the rooms `yhikas_room_pages` knows about (newest\n * first, at most {@link MAX_LISTED_ROOM_PAGES}):\n *\n * - **waiting** — pages the SYNC created that are still drafts and have never\n * been published: \"N new rooms wait for text and photos\".\n * - **retired** — LIVE room pages (base row published, or ANY locale\n * published on its own) whose room type is no longer published\n * in yhikas-admin (the sync's way of retiring a room): \"Room X no longer\n * exists in yhikas-admin — unpublish?\". Only when the viewer may read room\n * types; otherwise the list is not drawn, rather than guessed.\n * - **refused** — rooms whose page could not be created, with the reason the\n * last run kept on the registry row (\"Room X could not get a page: …\").\n *\n * Every read is the VIEWER's own (the dashboard's `createClient`); none\n * elevates. A viewer who may not read pages sees nothing at all.\n */\n\nimport { DEFAULT_LIMIT } from '@murumets-ee/db'\nimport { chunksOf, type RoomPageRecord } from '../room-page-registry.js'\nimport type { RoomPageRefusalKind } from '../room-pages-table.js'\n\n/** The most room pages the widget reads and lists. */\nexport const MAX_LISTED_ROOM_PAGES = 50\n\nexport interface RoomPageRow {\n readonly id: string\n readonly title: string\n readonly status: string | null\n /** Set the first time the page was published; `null` when it never was. */\n readonly everPublished: boolean\n /** Some non-default locale is published on its own (`<entity>_locale_status`). */\n readonly localePublished: boolean\n}\n\nexport interface RoomPagesWidgetReaders {\n /** The newest recorded room pages, at most `limit`. */\n readonly records: (limit: number) => Promise<readonly RoomPageRecord[]>\n /** These pages, as the viewer may read them. Throws when the viewer may not read pages at all. */\n readonly pages: (ids: readonly string[]) => Promise<readonly RoomPageRow[]>\n /**\n * Which of these codes are PUBLISHED room types. Throws when the viewer may\n * not read room types — the retired list is then left out.\n */\n readonly publishedCodes: (codes: readonly string[]) => Promise<ReadonlySet<string>>\n}\n\nexport interface ListedRoomPage {\n readonly code: string\n readonly pageId: string\n readonly title: string\n}\n\nexport interface RefusedRoomPage {\n readonly code: string\n readonly kind: RoomPageRefusalKind\n readonly message: string\n}\n\nexport interface RoomPagesWidgetData {\n readonly waiting: readonly ListedRoomPage[]\n readonly retired: readonly ListedRoomPage[]\n readonly refused: readonly RefusedRoomPage[]\n /** `false` when the viewer may not read room types, so retired rooms could not be checked. */\n readonly retiredChecked: boolean\n}\n\n/** One page's per-locale publish states, as `ContentClient.getLocaleStatusBatch` answers them. */\nexport type LocaleStatusMap = ReadonlyMap<\n string,\n Readonly<Record<string, { readonly status: string }>>\n>\n\n/**\n * `getLocaleStatusBatch` takes ids only and reads through `TableClient.findMany`\n * with NO limit — i.e. the default {@link DEFAULT_LIMIT} rows, silently. A page\n * has at most one row per non-default locale, so ids are read in chunks of\n * `floor(DEFAULT_LIMIT / (locales − 1))`, which cannot reach the cap. A chunk\n * that reaches it anyway (rows for locales no longer registered) may be\n * truncated — which would hide a still-published locale — so it is read again\n * one page at a time. Nothing is dropped; the re-read is bounded by the chunk.\n *\n * @param localeCount Every registered content locale, the default included —\n * enabled or not, since a disabled locale's status rows still exist.\n */\nexport async function readLocaleStatusChunked(\n read: (ids: string[]) => Promise<LocaleStatusMap>,\n ids: readonly string[],\n localeCount: number,\n): Promise<LocaleStatusMap> {\n const rowsPerId = Math.max(1, localeCount - 1)\n const chunkSize = Math.max(1, Math.floor(DEFAULT_LIMIT / rowsPerId))\n const out = new Map<string, Readonly<Record<string, { readonly status: string }>>>()\n for (const chunk of chunksOf([...new Set(ids)], chunkSize)) {\n const answer = await read(chunk)\n let rows = 0\n for (const entry of answer.values()) rows += Object.keys(entry).length\n const complete = rows < DEFAULT_LIMIT || chunk.length === 1\n const answers: LocaleStatusMap[] = complete ? [answer] : []\n if (!complete) {\n // Possibly truncated: one page per read. Sequential — bounded by the chunk.\n for (const id of chunk) answers.push(await read([id]))\n }\n for (const part of answers) for (const [id, entry] of part) out.set(id, entry)\n }\n return out\n}\n\n/**\n * The widget's data, or `null` when the viewer may not read pages — the\n * widget then renders nothing.\n */\nexport async function loadRoomPagesWidgetData(\n readers: RoomPagesWidgetReaders,\n): Promise<RoomPagesWidgetData | null> {\n const records = await readers.records(MAX_LISTED_ROOM_PAGES)\n const withPage = records.flatMap((record) =>\n record.pageId === null ? [] : [{ ...record, pageId: record.pageId }],\n )\n const refused: RefusedRoomPage[] = records.flatMap((record) =>\n record.pageId === null && record.lastError !== null\n ? [{ code: record.code, kind: record.lastErrorKind ?? 'refused', message: record.lastError }]\n : [],\n )\n\n let pages: readonly RoomPageRow[] = []\n try {\n // Asked even with no page to read: a viewer who may not read pages gets\n // NOTHING — the refusals included.\n pages = await readers.pages(withPage.map((record) => record.pageId))\n } catch {\n // The viewer may not read pages: there is nothing this widget may show\n // them. Not logged — a role without the grant is not an error.\n return null\n }\n const byId = new Map(pages.map((page) => [page.id, page]))\n\n const waiting: ListedRoomPage[] = []\n for (const record of withPage) {\n const page = byId.get(record.pageId)\n if (record.origin !== 'created' || page === undefined) continue\n if (page.status !== 'published' && !page.everPublished && !page.localePublished) {\n waiting.push({ code: record.code, pageId: record.pageId, title: page.title })\n }\n }\n\n const livePages = withPage.flatMap((record) => {\n const page = byId.get(record.pageId)\n return page !== undefined && (page.status === 'published' || page.localePublished)\n ? [{ code: record.code, pageId: record.pageId, title: page.title }]\n : []\n })\n if (livePages.length === 0) return { waiting, retired: [], refused, retiredChecked: true }\n\n let published: ReadonlySet<string>\n try {\n published = await readers.publishedCodes(livePages.map((page) => page.code))\n } catch {\n // The viewer may not read room types — say nothing rather than call every\n // room retired.\n return { waiting, retired: [], refused, retiredChecked: false }\n }\n return {\n waiting,\n retired: livePages.filter((page) => !published.has(page.code)),\n refused,\n retiredChecked: true,\n }\n}\n","/**\n * Dashboard widget — room pages that need a person (block-library PR21, D010).\n *\n * Server component, mounted from `Plugin.adminUi.dashboardWidgets` only when\n * the site set `roomPages`. Reads at render time, so first paint already\n * shows the list — no client `useEffect`, no fetch waterfall. The one\n * interactive piece, \"Unpublish\", is a client island\n * (`@murumets-ee/yhikas-sync/widgets-client`).\n *\n * 🔴 Every entity read is the VIEWER's: `config.createClient` is the admin\n * shell's request-bound client factory. Nothing here elevates. A viewer who\n * may not read pages gets nothing; one who may not read room types gets the\n * waiting list without the retired one. The Unpublish button is drawn only\n * for a viewer whose role holds `publish` on the page entity — checked here,\n * on the server, with the admin shell's own permission checker\n * (`hasEntityPermission`); everyone else reads the sentence without it.\n *\n * Every word is a `YhikasSync.*` key resolved with an UNSCOPED translator,\n * falling back to the English sentence — never to the key — when the app has\n * not merged this package's catalogue (CLAUDE.md, interface copy).\n */\n\nimport type { RequireEntityPermissionConfig } from '@murumets-ee/admin-ui/pages'\nimport type { Entity } from '@murumets-ee/entity'\nimport { UnpublishRoomPageButton } from '@murumets-ee/yhikas-sync/widgets-client'\nimport type { ReactElement } from 'react'\nimport type { AdminClientLike } from '../entity-client.js'\nimport {\n loadRoomPagesWidgetData,\n type RoomPageRow,\n type RoomPagesWidgetData,\n readLocaleStatusChunked,\n} from './room-pages-data.js'\n\nexport interface RoomPagesWidgetProps {\n /**\n * The dashboard passes its whole `AdminPagesConfig`; this widget reads\n * these. `createClient`, `auth` and `loadRoles` are optional only because\n * the declared widget prop shape does not promise them — without\n * `createClient` nothing is drawn, without `auth`/`loadRoles` no button is.\n */\n readonly config: {\n readonly adminBasePath: string\n readonly apiBasePath?: string | undefined\n readonly createClient?: ((entity: Entity) => Promise<AdminClientLike>) | undefined\n readonly auth?: RequireEntityPermissionConfig['auth'] | undefined\n readonly loadRoles?: RequireEntityPermissionConfig['loadRoles'] | undefined\n }\n}\n\n/** A translator's surface this widget uses — `next-intl`'s, structurally. */\ninterface Translate {\n (key: string, values?: Record<string, string | number>): string\n has(key: string): boolean\n}\n\nconst textOf = (value: unknown): string | null => (typeof value === 'string' ? value : null)\n\n/** The key's sentence, or the English one when the catalogue is not merged — never the key. */\nfunction say(\n t: Translate,\n key: string,\n english: string,\n values?: Record<string, string | number>,\n): string {\n return t.has(key) ? t(key, values) : english\n}\n\ninterface Loaded {\n readonly data: RoomPagesWidgetData\n readonly pageEntity: Entity\n readonly defaultLocale: string\n readonly locales: readonly string[]\n}\n\nasync function loadData(config: RoomPagesWidgetProps['config']): Promise<Loaded | null> {\n const createClient = config.createClient\n if (createClient === undefined) return null\n const { getApp, getPluginConfig, hasPluginConfig } = await import('@murumets-ee/core')\n const { YHIKAS_SYNC_PLUGIN_NAME } = await import('../constants.js')\n if (!hasPluginConfig(YHIKAS_SYNC_PLUGIN_NAME)) return null\n const roomPages = getPluginConfig(YHIKAS_SYNC_PLUGIN_NAME).roomPages\n if (roomPages === undefined) return null\n\n const app = getApp()\n const pageEntity = app.entities.get(roomPages.entity)\n if (pageEntity === undefined) return null\n // Deny by default, asked EXPLICITLY: the reads below would refuse a viewer\n // who may not view pages, but only when they run — and with nothing to\n // read (only refusals recorded) none does, so a refusal list naming\n // toolkit_paths rows and entity ids would reach that viewer.\n if (!(await mayDo(config, pageEntity, 'view'))) return null\n const { YHIKAS_ROOM_TYPE_ENTITY, YhikasRoomType } = await import('../entities/room-type.js')\n const roomTypeEntity = app.entities.get(YHIKAS_ROOM_TYPE_ENTITY) ?? YhikasRoomType\n const { createRoomPageRegistry } = await import('../room-page-registry.js')\n const { yhikasRoomPagesTable } = await import('../room-pages-table.js')\n const { columnOf, toRoomTypeReader } = await import('../room-pages-adapters.js')\n const { inArray } = await import('drizzle-orm')\n const { ContentClient } = await import('@murumets-ee/content/client')\n const { enabledLocaleCodes, resolveEffectiveLocales, resolveI18nConfig } = await import(\n '@murumets-ee/content'\n )\n const { defaultLocale } = await resolveI18nConfig(app)\n // Every registered locale, enabled or not — the chunking bound above.\n const registeredLocales = await resolveEffectiveLocales(app)\n\n const data = await loadRoomPagesWidgetData({\n records: (limit) =>\n createRoomPageRegistry(yhikasRoomPagesTable.makeClient(app.db.readOnly)).list(limit),\n pages: async (ids): Promise<readonly RoomPageRow[]> => {\n // The VIEWER's client: a refusal here means they may not read pages.\n const client = await createClient(pageEntity)\n if (ids.length === 0) return []\n const rows: readonly Readonly<Record<string, unknown>>[] = await client.findMany({\n where: inArray(columnOf(client.getTable(), roomPages.entity, 'id'), [...ids]),\n limit: ids.length,\n locale: defaultLocale,\n defaultLocale,\n })\n const readIds = rows.flatMap((row) => {\n const id = textOf(row.id)\n return id === null ? [] : [id]\n })\n // Per-locale publish state, through the content client over the SAME\n // viewer client — only for rows the viewer could read. Chunked: the\n // batch read has no limit of its own (see `readLocaleStatusChunked`).\n const content = new ContentClient({ admin: client, entity: pageEntity })\n const localeStatus = await readLocaleStatusChunked(\n (chunk) => content.getLocaleStatusBatch(chunk),\n readIds,\n registeredLocales.length,\n )\n // ONE locale set for \"is it live\" and for what Unpublish sends: the\n // ENABLED locales — a disabled locale is not served, and counting it\n // would list a page Unpublish can never clear.\n const enabled = new Set(enabledLocaleCodes(registeredLocales))\n return rows.flatMap((row) => {\n const id = textOf(row.id)\n if (id === null) return []\n const locales = Object.entries(localeStatus.get(id) ?? {})\n .filter(([code]) => enabled.has(code))\n .map(([, entry]) => entry)\n return [\n {\n id,\n title: textOf(row.title) ?? '',\n status: textOf(row.status),\n everPublished: row.publishedAt !== null && row.publishedAt !== undefined,\n localePublished: locales.some((entry) => entry.status === 'published'),\n },\n ]\n })\n },\n publishedCodes: async (codes) => {\n const reader = toRoomTypeReader(await createClient(roomTypeEntity), defaultLocale)\n const rooms = await reader.published(defaultLocale, { limit: codes.length, codes })\n return new Set(rooms.map((room) => room.code))\n },\n })\n if (data === null) return null\n // Only needed for the Unpublish button — read only when there is one.\n const locales = data.retired.length > 0 ? enabledLocaleCodes(registeredLocales) : []\n return { data, pageEntity, defaultLocale, locales }\n}\n\n/** May this viewer `view` / `publish` the page entity? Fail-closed. */\nasync function mayDo(\n config: RoomPagesWidgetProps['config'],\n entity: Entity,\n action: 'view' | 'publish',\n): Promise<boolean> {\n const { auth, loadRoles } = config\n if (auth === undefined || loadRoles === undefined) return false\n try {\n const { hasEntityPermission } = await import('@murumets-ee/admin-ui/pages')\n return await hasEntityPermission(entity, action, { auth, loadRoles })\n } catch (err) {\n console.error(`[yhikas-sync] room pages widget could not check ${action} permission`, err)\n return false\n }\n}\n\nexport async function RoomPagesWidget({\n config,\n}: RoomPagesWidgetProps): Promise<ReactElement | null> {\n let loaded: Loaded | null\n try {\n loaded = await loadData(config)\n } catch (err) {\n // A widget must never take the dashboard down; the dashboard also logs\n // a throw, but saying what failed here is cheaper to read.\n console.error('[yhikas-sync] room pages widget could not load', err)\n return null\n }\n if (loaded === null) return null\n const { data, pageEntity, defaultLocale, locales } = loaded\n if (data.waiting.length === 0 && data.retired.length === 0 && data.refused.length === 0) {\n return null\n }\n\n const { getLocale, getTranslations } = await import('next-intl/server')\n const { entityNameToSlug } = await import('@murumets-ee/admin-ui/server')\n const locale = await getLocale()\n // UNSCOPED: the keys are this package's, under `YhikasSync`.\n const t: Translate = await getTranslations()\n // `adminBasePath` is a template like `/:locale/admin` (see the queue widget).\n const adminBase = config.adminBasePath.replace(':locale', locale)\n const apiBase = (config.apiBasePath ?? '/api/admin').replace(/\\/+$/, '')\n const pagesSlug = entityNameToSlug(pageEntity.name)\n const otherLocales = locales.filter((code) => code !== defaultLocale)\n const canUnpublish = data.retired.length > 0 && (await mayDo(config, pageEntity, 'publish'))\n\n const waitingCount = data.waiting.length\n return (\n <section className=\"rounded-xl border bg-card p-4 text-card-foreground shadow-sm\">\n <h2 className=\"text-sm font-medium\">{say(t, 'YhikasSync.roomPages.title', 'Room pages')}</h2>\n {waitingCount > 0 ? (\n <div className=\"mt-3\">\n <p className=\"text-sm\">\n {say(\n t,\n 'YhikasSync.roomPages.waiting',\n waitingCount === 1\n ? '1 new room waits for text and photos'\n : `${waitingCount} new rooms wait for text and photos`,\n { count: waitingCount },\n )}\n </p>\n <ul className=\"mt-2 space-y-1\">\n {data.waiting.map((page) => (\n <li key={page.pageId} className=\"flex items-center justify-between gap-2 text-sm\">\n <span className=\"truncate\">\n <span className=\"font-mono text-xs text-muted-foreground\">{page.code}</span>{' '}\n {page.title}\n </span>\n <a\n href={`${adminBase}/${pagesSlug}/${encodeURIComponent(page.pageId)}`}\n className=\"shrink-0 text-xs underline hover:text-foreground\"\n >\n {say(t, 'YhikasSync.roomPages.open', 'Open')}\n </a>\n </li>\n ))}\n </ul>\n </div>\n ) : null}\n {data.retired.length > 0 ? (\n <ul className=\"mt-3 space-y-2\">\n {data.retired.map((page) => (\n <li key={page.pageId} className=\"flex items-start justify-between gap-2 text-sm\">\n {canUnpublish ? (\n <>\n <span className=\"text-rose-700 dark:text-rose-300\">\n {say(\n t,\n 'YhikasSync.roomPages.retired',\n `Room ${page.code} no longer exists in yhikas-admin — unpublish?`,\n { code: page.code },\n )}\n </span>\n <UnpublishRoomPageButton\n url={`${apiBase}/${encodeURIComponent(pageEntity.name)}/${encodeURIComponent(page.pageId)}`}\n defaultLocale={defaultLocale}\n locales={otherLocales}\n label={say(t, 'YhikasSync.roomPages.unpublish', 'Unpublish')}\n busyLabel={say(t, 'YhikasSync.roomPages.unpublishing', 'Unpublishing…')}\n failedTemplate={say(\n t,\n 'YhikasSync.roomPages.unpublishFailed',\n 'Not unpublished: {failures}. Nothing is refreshed until every language is down — try again.',\n { failures: '{failures}' },\n )}\n />\n </>\n ) : (\n <span className=\"text-rose-700 dark:text-rose-300\">\n {say(\n t,\n 'YhikasSync.roomPages.retiredAsk',\n `Room ${page.code} no longer exists in yhikas-admin, but its page is still published. Someone who may publish pages should unpublish it.`,\n { code: page.code },\n )}\n </span>\n )}\n </li>\n ))}\n </ul>\n ) : null}\n {data.refused.length > 0 ? (\n <ul className=\"mt-3 space-y-2\">\n {data.refused.map((room) => (\n <li key={`refused-${room.code}`} className=\"text-sm text-amber-700 dark:text-amber-300\">\n {room.kind === 'room-type-path'\n ? say(\n t,\n 'YhikasSync.roomPages.refusedRoomTypePath',\n `Room ${room.code} could not get a page: its address is still held by the room type's old URL (${room.message}). Remove the yhikas_room_type rows from the path index (toolkit_paths); the next sync then creates the page.`,\n { code: room.code, reason: room.message },\n )\n : say(\n t,\n 'YhikasSync.roomPages.refused',\n `Room ${room.code} could not get a page: ${room.message}`,\n { code: room.code, reason: room.message },\n )}\n </li>\n ))}\n </ul>\n ) : null}\n </section>\n )\n}\n"],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@murumets-ee/yhikas-sync",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.94.0",
|
|
4
4
|
"license": "Elastic-2.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -11,6 +11,18 @@
|
|
|
11
11
|
"./plugin": {
|
|
12
12
|
"types": "./dist/plugin.d.mts",
|
|
13
13
|
"import": "./dist/plugin.mjs"
|
|
14
|
+
},
|
|
15
|
+
"./i18n": {
|
|
16
|
+
"types": "./dist/i18n.d.mts",
|
|
17
|
+
"import": "./dist/i18n.mjs"
|
|
18
|
+
},
|
|
19
|
+
"./widgets": {
|
|
20
|
+
"types": "./dist/widgets.d.mts",
|
|
21
|
+
"import": "./dist/widgets.mjs"
|
|
22
|
+
},
|
|
23
|
+
"./widgets-client": {
|
|
24
|
+
"types": "./dist/widgets-client.d.mts",
|
|
25
|
+
"import": "./dist/widgets-client.mjs"
|
|
14
26
|
}
|
|
15
27
|
},
|
|
16
28
|
"files": [
|
|
@@ -19,15 +31,37 @@
|
|
|
19
31
|
"dependencies": {
|
|
20
32
|
"drizzle-orm": "^0.45.2",
|
|
21
33
|
"zod": "^3.24.1",
|
|
22
|
-
"@murumets-ee/
|
|
23
|
-
"@murumets-ee/
|
|
24
|
-
"@murumets-ee/core": "0.
|
|
25
|
-
"@murumets-ee/db": "0.
|
|
26
|
-
"@murumets-ee/
|
|
27
|
-
"@murumets-ee/
|
|
34
|
+
"@murumets-ee/content": "0.94.0",
|
|
35
|
+
"@murumets-ee/admin-ui": "0.94.0",
|
|
36
|
+
"@murumets-ee/core": "0.94.0",
|
|
37
|
+
"@murumets-ee/db": "0.94.0",
|
|
38
|
+
"@murumets-ee/blocks": "0.94.0",
|
|
39
|
+
"@murumets-ee/entity": "0.94.0",
|
|
40
|
+
"@murumets-ee/queue": "0.94.0"
|
|
41
|
+
},
|
|
42
|
+
"peerDependencies": {
|
|
43
|
+
"next": ">=16.0.0 <17",
|
|
44
|
+
"next-intl": ">=4.0.0 <5",
|
|
45
|
+
"react": ">=19.0.0 <20"
|
|
46
|
+
},
|
|
47
|
+
"peerDependenciesMeta": {
|
|
48
|
+
"next": {
|
|
49
|
+
"optional": true
|
|
50
|
+
},
|
|
51
|
+
"next-intl": {
|
|
52
|
+
"optional": true
|
|
53
|
+
},
|
|
54
|
+
"react": {
|
|
55
|
+
"optional": true
|
|
56
|
+
}
|
|
28
57
|
},
|
|
29
58
|
"devDependencies": {
|
|
30
59
|
"@types/node": "^20.19.42",
|
|
60
|
+
"@types/react": "^19",
|
|
61
|
+
"next": "16.3.0",
|
|
62
|
+
"next-intl": "^4.13.0",
|
|
63
|
+
"react": "19.2.7",
|
|
64
|
+
"react-dom": "19.2.7",
|
|
31
65
|
"tsdown": "^0.22.2",
|
|
32
66
|
"typescript": "^5.7.3",
|
|
33
67
|
"vitest": "^2.1.8"
|