@portaki/module-sections 1.2.6 → 1.3.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/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@portaki/module-sections",
3
- "version": "1.2.6",
3
+ "version": "1.3.0",
4
4
  "description": "Portaki module — editorial sections (TipTap)",
5
5
  "license": "AGPL-3.0",
6
6
  "type": "module",
7
7
  "main": "./src/index.tsx",
8
8
  "dependencies": {
9
- "@portaki/sdk": "^2.0.6",
9
+ "@portaki/sdk": "^3.0.0",
10
10
  "@tiptap/core": "^3.0.0",
11
11
  "@tiptap/html": "^3.0.0",
12
12
  "@tiptap/starter-kit": "^3.0.0",
@@ -14,8 +14,8 @@
14
14
  "react-dom": "^19.1.0"
15
15
  },
16
16
  "devDependencies": {
17
- "@portaki/cli": "^0.1.5",
18
- "@portaki/sdk-test-support": "^2.0.7",
17
+ "@portaki/cli": "^1.0.0",
18
+ "@portaki/sdk-test-support": "^3.0.0",
19
19
  "@testing-library/jest-dom": "^6.6.3",
20
20
  "@testing-library/react": "^16.3.0",
21
21
  "@types/react": "^19",
@@ -9,7 +9,7 @@
9
9
  "fr": "Blocs de contenu riches (TipTap) pour le carnet d’accueil invité.",
10
10
  "en": "Rich content blocks (TipTap) for the guest welcome book."
11
11
  },
12
- "version": "1.2.6",
12
+ "version": "1.3.0",
13
13
  "releaseNotesUrl": "https://github.com/PortakiApp/portaki-modules/releases",
14
14
  "changelog": [
15
15
  {
@@ -42,7 +42,7 @@
42
42
  "sections"
43
43
  ],
44
44
  "portakiVersionMin": "1.0.0",
45
- "requiresHostSdk": "2.0.6",
45
+ "requiresHostSdk": "3.0.0",
46
46
  "database": {
47
47
  "schemaVersion": "1.0.1"
48
48
  },
@@ -122,6 +122,6 @@
122
122
  },
123
123
  "artifacts": {
124
124
  "wasmUrl": "oci://ghcr.io/portakiapp/portaki-module-sections",
125
- "guestEsmUrl": "https://esm.sh/@portaki/module-sections@1.2.6"
125
+ "guestEsmUrl": "https://esm.sh/@portaki/module-sections@1.3.0"
126
126
  }
127
127
  }
@@ -9,7 +9,7 @@
9
9
  "fr": "Blocs de contenu riches (TipTap) pour le carnet d’accueil invité.",
10
10
  "en": "Rich content blocks (TipTap) for the guest welcome book."
11
11
  },
12
- "version": "1.2.6",
12
+ "version": "1.3.0",
13
13
  "releaseNotesUrl": "https://github.com/PortakiApp/portaki-modules/releases",
14
14
  "changelog": [
15
15
  {
@@ -42,9 +42,9 @@
42
42
  "sections"
43
43
  ],
44
44
  "portakiVersionMin": "1.0.0",
45
- "requiresHostSdk": "2.0.6",
45
+ "requiresHostSdk": "3.0.0",
46
46
  "database": {
47
- "schemaVersion": "1.0.1"
47
+ "schemaVersion": "1.0.2"
48
48
  },
49
49
  "license": "AGPL-3.0",
50
50
  "repository": "https://github.com/PortakiApp/portaki-modules",
@@ -122,6 +122,6 @@
122
122
  },
123
123
  "artifacts": {
124
124
  "wasmUrl": "oci://ghcr.io/portakiapp/portaki-module-sections",
125
- "guestEsmUrl": "https://esm.sh/@portaki/module-sections@1.2.6"
125
+ "guestEsmUrl": "https://esm.sh/@portaki/module-sections@1.3.0"
126
126
  }
127
127
  }
@@ -16,10 +16,6 @@ type SectionRow = {
16
16
  contentEn: unknown
17
17
  }
18
18
 
19
- type ListResponse = {
20
- sections: SectionRow[]
21
- }
22
-
23
19
  const htmlExtensions = [StarterKit]
24
20
 
25
21
  function contentToHtml(raw: unknown): string {
@@ -33,7 +29,7 @@ function contentToHtml(raw: unknown): string {
33
29
  }
34
30
 
35
31
  export function SectionsGuestView({ lang }: { lang: LangCode }) {
36
- const { data, loading, error } = usePortakiQuery<ListResponse>('sections.list', {})
32
+ const { data, loading, error } = usePortakiQuery<SectionRow[]>('sections.list', {})
37
33
 
38
34
  if (loading) {
39
35
  return (
@@ -43,13 +39,13 @@ export function SectionsGuestView({ lang }: { lang: LangCode }) {
43
39
  )
44
40
  }
45
41
 
46
- if (error != null || !data?.sections?.length) {
42
+ if (error != null || !data?.length) {
47
43
  return null
48
44
  }
49
45
 
50
46
  return (
51
47
  <div className="space-y-10">
52
- {data.sections.map((section) => {
48
+ {data.map((section) => {
53
49
  const title = lang === 'en' ? section.titleEn || section.titleFr : section.titleFr
54
50
  const html = contentToHtml(lang === 'en' ? section.contentEn : section.contentFr)
55
51
  return (
@@ -1,12 +1,11 @@
1
1
  import {
2
2
  defineModule,
3
- index,
4
3
  int,
5
4
  jsonb,
6
5
  moduleSchema,
7
6
  table,
8
- tenantId,
9
- tenantPropertyIndex,
7
+ workspaceId,
8
+ workspacePropertyIndex,
10
9
  text,
11
10
  timestamptz,
12
11
  uuid,
@@ -21,19 +20,30 @@ const sectionsSchema = moduleSchema([
21
20
  columns: [
22
21
  uuidPrimaryKey(),
23
22
  uuid('propertyId'),
24
- tenantId(),
23
+ workspaceId(),
25
24
  int('sortOrder', { defaultSql: '0' }),
26
- text('titleFr', { defaultSql: "''" }),
27
- text('titleEn', { defaultSql: "''" }),
28
- jsonb('contentFr', { nullable: true }),
29
- jsonb('contentEn', { nullable: true }),
30
25
  timestamptz('createdAt', { nullable: false, defaultSql: 'now()' }),
31
26
  timestamptz('updatedAt', { nullable: false, defaultSql: 'now()' }),
32
27
  ],
33
- indexes: [tenantPropertyIndex()],
28
+ indexes: [workspacePropertyIndex()],
29
+ }),
30
+ table('itemLocales', 't_e_module_sections_item_locale', {
31
+ columns: [
32
+ uuid('sectionId', { primaryKey: true }),
33
+ text('lang', { primaryKey: true }),
34
+ workspaceId(),
35
+ uuid('propertyId'),
36
+ text('title', { defaultSql: "''" }),
37
+ jsonb('content', { nullable: true }),
38
+ ],
39
+ indexes: [workspacePropertyIndex()],
34
40
  }),
35
41
  ])
36
42
 
43
+ const SECTION_LANGS = ['fr', 'en'] as const
44
+
45
+ type SectionLang = (typeof SECTION_LANGS)[number]
46
+
37
47
  function stringParam(params: Record<string, unknown>, key: string): string | null {
38
48
  const value = params[key]
39
49
  if (value == null) {
@@ -53,6 +63,75 @@ function intParam(params: Record<string, unknown>, key: string, defaultValue: nu
53
63
  return Number.parseInt(String(value), 10)
54
64
  }
55
65
 
66
+ function localeTitleForLang(
67
+ lang: SectionLang,
68
+ titleFr: string,
69
+ titleEn: string,
70
+ ): string {
71
+ if (lang === 'fr') {
72
+ return titleFr
73
+ }
74
+ return titleEn.trim() === '' ? titleFr : titleEn
75
+ }
76
+
77
+ function localeContentForLang(
78
+ lang: SectionLang,
79
+ contentFr: unknown,
80
+ contentEn: unknown,
81
+ ): unknown {
82
+ if (lang === 'fr') {
83
+ return contentFr ?? null
84
+ }
85
+ return contentEn ?? null
86
+ }
87
+
88
+ async function upsertSectionLocale(
89
+ ctx: ModuleContext,
90
+ filters: { workspaceId: string; propertyId: string },
91
+ sectionId: string,
92
+ lang: SectionLang,
93
+ title: string,
94
+ content: unknown,
95
+ ): Promise<void> {
96
+ const where = { ...filters, sectionId, lang }
97
+ const existing = await ctx.db.from('itemLocales').where(where).one()
98
+ const payload = { title, content }
99
+ if (existing != null) {
100
+ await ctx.db.from('itemLocales').where(where).update(payload)
101
+ return
102
+ }
103
+ await ctx.db.from('itemLocales').insert({
104
+ ...where,
105
+ ...payload,
106
+ })
107
+ }
108
+
109
+ function sectionDtoFromLocales(
110
+ item: Record<string, unknown>,
111
+ locales: Record<string, unknown>[],
112
+ ): Record<string, unknown> {
113
+ const byLang = new Map<string, Record<string, unknown>>()
114
+ for (const row of locales) {
115
+ byLang.set(String(row.lang), row)
116
+ }
117
+ const fr = byLang.get('fr')
118
+ const en = byLang.get('en')
119
+ const titleFr = String(fr?.title ?? '')
120
+ const titleEnRaw = String(en?.title ?? '')
121
+ const section: Record<string, unknown> = {
122
+ id: item.id,
123
+ sortOrder: item.sortOrder,
124
+ titleFr,
125
+ titleEn: titleEnRaw.trim() === '' ? titleFr : titleEnRaw,
126
+ contentFr: fr?.content ?? null,
127
+ contentEn: en?.content ?? null,
128
+ }
129
+ if (item.updatedAt != null) {
130
+ section.updatedAt = item.updatedAt
131
+ }
132
+ return section
133
+ }
134
+
56
135
  export function defineSectionsModule(render: (ctx: ModuleContext) => ReactNode): PortakiFullModule {
57
136
  return defineModule({
58
137
  id: 'sections',
@@ -62,8 +141,8 @@ export function defineSectionsModule(render: (ctx: ModuleContext) => ReactNode):
62
141
  en: 'Welcome book editorial content.',
63
142
  },
64
143
  icon: 'file-text',
65
- version: '1.2.4',
66
- schemaVersion: '1.0.1',
144
+ version: '1.2.7',
145
+ schemaVersion: '1.0.2',
67
146
  schema: sectionsSchema,
68
147
  navSlot: 'section',
69
148
 
@@ -75,10 +154,16 @@ export function defineSectionsModule(render: (ctx: ModuleContext) => ReactNode):
75
154
  en: 'Property editorial sections.',
76
155
  },
77
156
  async handler(ctx) {
78
- const rows = await ctx.db
79
- .from('items')
80
- .where({ tenantId: ctx.tenantId, propertyId: ctx.propertyId })
81
- .many()
157
+ const filters = { workspaceId: ctx.workspaceId, propertyId: ctx.propertyId }
158
+ const rows = await ctx.db.from('items').where(filters).many()
159
+ const locales = await ctx.db.from('itemLocales').where(filters).many()
160
+ const localesBySection = new Map<string, Record<string, unknown>[]>()
161
+ for (const locale of locales) {
162
+ const sectionId = String(locale.sectionId)
163
+ const bucket = localesBySection.get(sectionId) ?? []
164
+ bucket.push(locale)
165
+ localesBySection.set(sectionId, bucket)
166
+ }
82
167
  rows.sort((a, b) => {
83
168
  const orderA = Number(a.sortOrder ?? 0)
84
169
  const orderB = Number(b.sortOrder ?? 0)
@@ -89,21 +174,9 @@ export function defineSectionsModule(render: (ctx: ModuleContext) => ReactNode):
89
174
  const createdB = String(b.createdAt ?? '')
90
175
  return createdA.localeCompare(createdB)
91
176
  })
92
- const sections = rows.map((row) => {
93
- const section: Record<string, unknown> = {
94
- id: row.id,
95
- sortOrder: row.sortOrder,
96
- titleFr: row.titleFr,
97
- titleEn: row.titleEn,
98
- contentFr: row.contentFr ?? null,
99
- contentEn: row.contentEn ?? null,
100
- }
101
- if (row.updatedAt != null) {
102
- section.updatedAt = row.updatedAt
103
- }
104
- return section
105
- })
106
- return { sections }
177
+ return rows.map((row) =>
178
+ sectionDtoFromLocales(row, localesBySection.get(String(row.id)) ?? []),
179
+ )
107
180
  },
108
181
  },
109
182
  },
@@ -116,7 +189,7 @@ export function defineSectionsModule(render: (ctx: ModuleContext) => ReactNode):
116
189
  en: 'Create or update a section.',
117
190
  },
118
191
  async handler(ctx, params) {
119
- const filters = { tenantId: ctx.tenantId, propertyId: ctx.propertyId }
192
+ const filters = { workspaceId: ctx.workspaceId, propertyId: ctx.propertyId }
120
193
  const idRaw = stringParam(params, 'id')
121
194
  const id =
122
195
  idRaw == null || idRaw.trim() === '' ? crypto.randomUUID() : idRaw.trim()
@@ -146,24 +219,30 @@ export function defineSectionsModule(render: (ctx: ModuleContext) => ReactNode):
146
219
  sortOrder = max + 1
147
220
  }
148
221
  const now = new Date().toISOString()
149
- const payload = {
150
- titleFr,
151
- titleEn,
152
- contentFr,
153
- contentEn,
222
+ const itemPayload = {
154
223
  sortOrder,
155
224
  updatedAt: now,
156
225
  }
157
226
  if (existing != null) {
158
- await ctx.db.from('items').where({ ...filters, id }).update(payload)
159
- return
227
+ await ctx.db.from('items').where({ ...filters, id }).update(itemPayload)
228
+ } else {
229
+ await ctx.db.from('items').insert({
230
+ id,
231
+ ...filters,
232
+ ...itemPayload,
233
+ createdAt: now,
234
+ })
235
+ }
236
+ for (const lang of SECTION_LANGS) {
237
+ await upsertSectionLocale(
238
+ ctx,
239
+ filters,
240
+ id,
241
+ lang,
242
+ localeTitleForLang(lang, titleFr, titleEn),
243
+ localeContentForLang(lang, contentFr, contentEn),
244
+ )
160
245
  }
161
- await ctx.db.from('items').insert({
162
- id,
163
- ...filters,
164
- ...payload,
165
- createdAt: now,
166
- })
167
246
  },
168
247
  },
169
248
  'sections.section.delete': {
@@ -177,14 +256,13 @@ export function defineSectionsModule(render: (ctx: ModuleContext) => ReactNode):
177
256
  if (idRaw == null || idRaw.trim() === '') {
178
257
  throw new Error('id_required')
179
258
  }
180
- const deleted = await ctx.db
181
- .from('items')
182
- .where({
183
- id: idRaw.trim(),
184
- tenantId: ctx.tenantId,
185
- propertyId: ctx.propertyId,
186
- })
187
- .delete()
259
+ const id = idRaw.trim()
260
+ const filters = {
261
+ id,
262
+ workspaceId: ctx.workspaceId,
263
+ propertyId: ctx.propertyId,
264
+ }
265
+ const deleted = await ctx.db.from('items').where(filters).delete()
188
266
  if (deleted === 0) {
189
267
  throw new Error('section_not_found')
190
268
  }
@@ -201,17 +279,17 @@ export function defineSectionsModule(render: (ctx: ModuleContext) => ReactNode):
201
279
  if (!Array.isArray(raw) || raw.length === 0) {
202
280
  throw new Error('ordered_ids_required')
203
281
  }
204
- const filters = { tenantId: ctx.tenantId, propertyId: ctx.propertyId }
282
+ const filters = { workspaceId: ctx.workspaceId, propertyId: ctx.propertyId }
205
283
  const now = new Date().toISOString()
206
284
  let order = 0
207
285
  for (const item of raw) {
208
286
  if (item == null) {
209
287
  continue
210
288
  }
211
- const id = String(item)
289
+ const sectionId = String(item)
212
290
  await ctx.db
213
291
  .from('items')
214
- .where({ ...filters, id })
292
+ .where({ ...filters, id: sectionId })
215
293
  .update({ sortOrder: order++, updatedAt: now })
216
294
  }
217
295
  },